1use std::collections::BTreeMap;
5
6use super::record::{snapshot, Day, Sessions, Top, BEHAVIOUR, DIMS};
7
8fn quote(s: &str) -> String {
9 let mut out = String::with_capacity(s.len() + 2);
10 out.push('"');
11 for c in s.chars() {
12 match c {
13 '"' => out.push_str("\\\""),
14 '\\' => out.push_str("\\\\"),
15 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
16 c => out.push(c),
17 }
18 }
19 out.push('"');
20 out
21}
22
23fn pairs(top: &Top, n: usize, host: Option<&str>) -> String {
24 let mut filtered = Top::default();
25 for (key, count) in &top.0 {
26 if host.is_none_or(|h| key.starts_with(h) || key == "(other)") {
27 filtered.0.insert(key.clone(), *count);
28 }
29 }
30 let rows: Vec<String> = filtered.sorted(n).iter().map(|(k, v)| format!("[{},{v}]", quote(k))).collect();
31 format!("[{}]", rows.join(","))
32}
33
34fn dim(top: &Top, n: usize, host: Option<&str>) -> String {
37 let mut merged = Top::default();
38 for (key, count) in &top.0 {
39 let (h, value) = key.split_once('\t').unwrap_or(("", key.as_str()));
40 if host.is_none_or(|wanted| h.eq_ignore_ascii_case(wanted)) || key == "(other)" {
41 *merged.0.entry(value.to_string()).or_default() += count;
42 }
43 }
44 let rows: Vec<String> = merged.sorted(n).iter().map(|(k, v)| format!("[{},{v}]", quote(k))).collect();
45 format!("[{}]", rows.join(","))
46}
47
48pub fn json(hours: u64, host: Option<&str>, now: u64) -> String {
50 let hours = hours.clamp(1, 24 * 90);
51 let (buckets, days) = snapshot(now);
52 let names = super::record::host_names();
53 let host_index = host.and_then(|h| names.iter().position(|n| n.eq_ignore_ascii_case(h))).map(|i| i as u8);
54 let wanted = |h: u8| host.is_none() || Some(h) == host_index;
55 let this_hour = now / 3600;
56 let start = this_hour + 1 - hours;
57 let previous_start = start.saturating_sub(hours);
58
59 let mut totals = [[0u64; 2]; 3];
60 let mut previous = [[0u64; 2]; 3];
61 let daily = hours > 48;
62 let mut series: BTreeMap<u64, [u64; 3]> = BTreeMap::new();
63 let mut first_hour = None;
64 for ((hour, h), counts) in &buckets {
65 if !wanted(*h) {
66 continue;
67 }
68 first_hour = Some(first_hour.map_or(*hour, |f: u64| f.min(*hour)));
69 let target = if *hour >= start && *hour <= this_hour {
70 Some(&mut totals)
71 } else if *hour >= previous_start && *hour < start {
72 Some(&mut previous)
73 } else {
74 None
75 };
76 if let Some(t) = target {
77 for g in 0..3 {
78 t[g][0] += counts[g].requests;
79 t[g][1] += counts[g].pages;
80 }
81 }
82 if *hour >= start && *hour <= this_hour {
83 let key = if daily { hour / 24 * 24 } else { *hour };
84 let point = series.entry(key).or_default();
85 for g in 0..3 {
86 point[g] += counts[g].requests;
87 }
88 }
89 }
90 let step = if daily { 24 } else { 1 };
92 let mut t = if daily { start / 24 * 24 } else { start };
93 while t <= this_hour {
94 series.entry(t).or_default();
95 t += step;
96 }
97
98 let start_day = start / 24;
99 let previous_day = previous_start / 24;
100 let mut lists = Day::default();
101 let mut visitors = [0u64; 3];
102 let mut previous_visitors = [0u64; 3];
103 let mut sessions = Sessions::default();
104 let mut previous_sessions = Sessions::default();
105 let host_sessions = |d: &Day, into: &mut Sessions| {
106 for (h, s) in &d.sessions {
107 if host.is_none_or(|wanted| h.eq_ignore_ascii_case(wanted)) {
108 into.add(s);
109 }
110 }
111 };
112 for (day, d) in &days {
113 if *day >= start_day {
114 host_sessions(d, &mut sessions);
115 } else if *day >= previous_day {
116 host_sessions(d, &mut previous_sessions);
117 }
118 if *day >= start_day {
119 lists.merge(d);
120 for g in 0..3 {
121 visitors[g] += d.visitors[g];
122 }
123 } else if *day >= previous_day {
124 for g in 0..3 {
125 previous_visitors[g] += d.visitors[g];
126 }
127 }
128 }
129
130 let group = |t: &[[u64; 2]; 3], v: &[u64; 3], g: usize| format!(r#"{{"requests":{},"pages":{},"visitors":{}}}"#, t[g][0], t[g][1], v[g]);
131 let groups = |t: &[[u64; 2]; 3], v: &[u64; 3]| format!(r#"{{"people":{},"ai":{},"bots":{}}}"#, group(t, v, 0), group(t, v, 1), group(t, v, 2));
132 let points: Vec<String> = series.iter().map(|(t, p)| format!(r#"{{"t":{},"people":{},"ai":{},"bots":{}}}"#, t * 3600, p[0], p[1], p[2])).collect();
133 let agents: Vec<String> = lists
134 .ai_agents
135 .sorted(25)
136 .iter()
137 .map(|(key, n)| {
138 let mut parts = key.split('\t');
139 let (name, operator, kind, verified) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next() == Some("true"));
140 format!(r#"{{"name":{},"operator":{},"kind":{},"verified":{verified},"requests":{n}}}"#, quote(name), quote(operator), quote(kind))
141 })
142 .collect();
143 let bots: Vec<String> = lists
144 .bots
145 .sorted(25)
146 .iter()
147 .map(|(key, n)| {
148 let (kind, name) = key.split_once('\t').unwrap_or((key, ""));
149 format!(r#"{{"kind":{},"name":{},"requests":{n}}}"#, quote(kind), quote(name))
150 })
151 .collect();
152 let dims: Vec<String> = DIMS.iter().enumerate().map(|(i, name)| format!("{}:{}", quote(name), dim(&lists.dims[i], if i == 0 { 50 } else { 25 }, host))).collect();
153 let mut on_page: Vec<(String, u64, u64)> = lists.behaviour[1]
155 .0
156 .iter()
157 .filter(|(k, _)| host.is_none_or(|h| k.starts_with(h)) && k.as_str() != "(other)")
158 .map(|(k, views)| (k.clone(), lists.behaviour[0].0.get(k).copied().unwrap_or(0) / (*views).max(1), *views))
159 .collect();
160 on_page.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
161 on_page.truncate(25);
162 let on_page: Vec<String> = on_page.iter().map(|(k, avg, views)| format!("[{},{avg},{views}]", quote(k))).collect();
163 let behaviour: Vec<String> = BEHAVIOUR
164 .iter()
165 .enumerate()
166 .skip(2)
167 .map(|(i, name)| format!("{}:{}", quote(name), dim(&lists.behaviour[i], 25, host)))
168 .collect();
169 let session_json = |s: &Sessions| format!(r#"{{"sessions":{},"bounces":{},"duration":{},"pages":{}}}"#, s.sessions, s.bounces, s.duration, s.pages);
170 let geo = super::geo::table().map_or("null".to_string(), |t| quote(&t.source));
171 format!(
172 r#"{{"hours":{hours},"bucket":"{}","since":{},"hosts":[{}],"host":{},"totals":{},"previous":{},"series":[{}],"people_pages":{},"ai_pages":{},"ai_agents":[{}],"bots":[{}],"referrers":{},"not_found":{},{},"geo_source":{geo},"sessions":{},"previous_sessions":{},"entry_pages":{},"exit_pages":{},"script":{},"store":{},"time_on_page":[{}],{}}}"#,
173 if daily { "day" } else { "hour" },
174 first_hour.map_or("null".to_string(), |h| (h * 3600).to_string()),
175 names.iter().map(|n| quote(n)).collect::<Vec<_>>().join(","),
176 host.map_or("null".to_string(), quote),
177 groups(&totals, &visitors),
178 groups(&previous, &previous_visitors),
179 points.join(","),
180 pairs(&lists.people_pages, 25, host),
181 pairs(&lists.ai_pages, 25, host),
182 agents.join(","),
183 bots.join(","),
184 pairs(&lists.referrers, 25, None),
185 pairs(&lists.not_found, 25, host),
186 dims.join(","),
187 session_json(&sessions),
188 session_json(&previous_sessions),
189 pairs(&lists.entry_pages, 25, host),
190 pairs(&lists.exit_pages, 25, host),
191 super::config::script(),
192 store_on(),
193 on_page.join(","),
194 behaviour.join(","),
195 )
196}
197
198pub fn live_json(host: Option<&str>, now: u64) -> String {
202 use std::collections::{HashMap, HashSet};
203 let prefix = host.map(|h| format!("{}/", h.to_ascii_lowercase()));
204 let views: Vec<_> = super::record::recent(now).into_iter().filter(|r| prefix.as_ref().is_none_or(|p| r.page.starts_with(p.as_str()))).collect();
205 let this_minute = now / 60;
206 let mut minutes = [0u64; 30];
207 let active_since = now.saturating_sub(5 * 60);
208 let mut active: HashSet<u64> = HashSet::new();
209 let mut pages = Top::default();
210 let mut countries: HashMap<u64, String> = HashMap::new();
211 for r in &views {
212 let ago = this_minute.saturating_sub(r.at / 60) as usize;
213 if ago < 30 {
214 minutes[29 - ago] += 1;
215 }
216 if r.at >= active_since {
217 active.insert(r.visitor);
218 *pages.0.entry(r.page.clone()).or_default() += 1;
219 let country = r.country.map_or("(unknown)".to_string(), |c| String::from_utf8_lossy(&c).into_owned());
220 countries.insert(r.visitor, country);
221 }
222 }
223 let mut by_country = Top::default();
224 for c in countries.values() {
225 *by_country.0.entry(c.clone()).or_default() += 1;
226 }
227 let series: Vec<String> = minutes.iter().enumerate().map(|(i, n)| format!(r#"{{"t":{},"views":{n}}}"#, (this_minute + 1 + i as u64 - 30) * 60)).collect();
228 format!(
229 r#"{{"now":{now},"host":{},"visitors":{},"views_5m":{},"series":[{}],"pages":{},"countries":{}}}"#,
230 host.map_or("null".to_string(), quote),
231 active.len(),
232 views.iter().filter(|r| r.at >= active_since).count(),
233 series.join(","),
234 pairs(&pages, 10, None),
235 pairs(&by_country, 10, None),
236 )
237}
238
239fn store_on() -> bool {
240 #[cfg(feature = "model-postgres")]
241 return super::store::enabled();
242 #[cfg(not(feature = "model-postgres"))]
243 false
244}