Skip to main content

rush_sync_server/server/
analytics.rs

1// src/server/analytics.rs
2//
3// Lightweight in-memory analytics tracker with periodic file persistence.
4// Filters out noise (health checks, bots, internal assets) and tracks
5// meaningful page views, downloads, unique visitors, and subdomain stats.
6
7use chrono::{Local, NaiveDate, TimeDelta};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::sync::{Arc, OnceLock, RwLock};
12
13static ANALYTICS: OnceLock<Arc<RwLock<AnalyticsTracker>>> = OnceLock::new();
14
15#[derive(Debug, Default, Serialize, Deserialize)]
16pub struct AnalyticsTracker {
17    days: HashMap<String, DayData>,
18    hourly: VecDeque<HourBucket>,
19}
20
21#[derive(Debug, Default, Clone, Serialize, Deserialize)]
22struct DayData {
23    total_views: u64,
24    total_downloads: u64,
25    unique_ips: HashSet<String>,
26    page_counts: HashMap<String, u64>,
27    download_counts: HashMap<String, u64>,
28    subdomain_views: HashMap<String, u64>,
29    subdomain_ips: HashMap<String, HashSet<String>>,
30}
31
32#[derive(Debug, Default, Clone, Serialize, Deserialize)]
33struct HourBucket {
34    hour: String,
35    views: u64,
36    unique_ips: HashSet<String>,
37}
38
39/// Get or initialize the global analytics tracker.
40/// On first call, loads persisted data from disk and starts periodic save.
41pub fn get_analytics() -> &'static Arc<RwLock<AnalyticsTracker>> {
42    ANALYTICS.get_or_init(|| {
43        let tracker = load_from_file().unwrap_or_default();
44        let arc = Arc::new(RwLock::new(tracker));
45
46        let arc_clone = arc.clone();
47        std::thread::spawn(move || {
48            let rt = tokio::runtime::Builder::new_current_thread()
49                .enable_all()
50                .build()
51                .expect("analytics save runtime");
52            rt.block_on(async move {
53                loop {
54                    tokio::time::sleep(tokio::time::Duration::from_secs(300)).await;
55                    if let Ok(tracker) = arc_clone.read() {
56                        if let Err(e) = save_to_file(&tracker) {
57                            log::error!("Failed to save analytics: {}", e);
58                        }
59                    }
60                }
61            });
62        });
63
64        log::info!("Analytics tracker initialized");
65        arc
66    })
67}
68
69/// Track a single request. Called from proxy handler and server middleware.
70/// Filters out non-trackable requests (health checks, bots, internal assets).
71pub fn track_request(subdomain: &str, path: &str, ip: &str, user_agent: &str) {
72    if !is_trackable_request(path, user_agent) {
73        return;
74    }
75
76    let analytics = get_analytics();
77    let mut tracker = match analytics.write() {
78        Ok(t) => t,
79        Err(_) => return,
80    };
81
82    let now = Local::now();
83    let date = now.format("%Y-%m-%d").to_string();
84    let hour_key = now.format("%Y-%m-%dT%H:00").to_string();
85    let ip_hash = hash_ip(ip);
86    let subdomain_key = if subdomain.is_empty() {
87        "direct"
88    } else {
89        subdomain
90    };
91
92    let clean_path = path.split('?').next().unwrap_or(path);
93
94    // Update day data
95    let day = tracker.days.entry(date).or_default();
96    day.total_views += 1;
97    day.unique_ips.insert(ip_hash.clone());
98    *day.page_counts.entry(clean_path.to_string()).or_default() += 1;
99    *day.subdomain_views
100        .entry(subdomain_key.to_string())
101        .or_default() += 1;
102    day.subdomain_ips
103        .entry(subdomain_key.to_string())
104        .or_default()
105        .insert(ip_hash.clone());
106
107    if is_download(clean_path) {
108        day.total_downloads += 1;
109        *day.download_counts
110            .entry(clean_path.to_string())
111            .or_default() += 1;
112    }
113
114    // Update hourly bucket
115    if let Some(bucket) = tracker.hourly.back_mut() {
116        if bucket.hour == hour_key {
117            bucket.views += 1;
118            bucket.unique_ips.insert(ip_hash);
119            return;
120        }
121    }
122    let mut ips = HashSet::new();
123    ips.insert(ip_hash);
124    tracker.hourly.push_back(HourBucket {
125        hour: hour_key,
126        views: 1,
127        unique_ips: ips,
128    });
129    while tracker.hourly.len() > 48 {
130        tracker.hourly.pop_front();
131    }
132}
133
134fn is_trackable_request(path: &str, user_agent: &str) -> bool {
135    let path_lower = path.to_lowercase();
136    let clean = path_lower.split('?').next().unwrap_or(&path_lower);
137
138    // Filter monitoring/internal endpoints
139    if matches!(
140        clean,
141        "/api/health"
142            | "/api/status"
143            | "/api/metrics"
144            | "/api/analytics"
145            | "/api/analytics/dashboard"
146            | "/api/logs"
147            | "/api/logs/raw"
148            | "/api/ping"
149    ) {
150        return false;
151    }
152
153    // Filter internal assets
154    if clean.starts_with("/.rss/")
155        || clean == "/rss.js"
156        || clean.starts_with("/ws/")
157        || clean.starts_with("/.well-known/")
158        || clean == "/favicon.ico"
159    {
160        return false;
161    }
162
163    // Filter bots/crawlers
164    let ua = user_agent.to_lowercase();
165    if ua.contains("bot")
166        || ua.contains("crawler")
167        || ua.contains("spider")
168        || ua.contains("curl")
169        || ua.contains("wget")
170        || ua.contains("python-requests")
171        || ua.contains("go-http-client")
172        || ua.contains("headlesschrome")
173        || ua.contains("phantomjs")
174    {
175        return false;
176    }
177
178    true
179}
180
181fn is_download(path: &str) -> bool {
182    let lower = path.to_lowercase();
183    lower.ends_with(".zip")
184        || lower.ends_with(".tar.gz")
185        || lower.ends_with(".exe")
186        || lower.ends_with(".dmg")
187        || lower.ends_with(".deb")
188        || lower.ends_with(".rpm")
189        || lower.ends_with(".msi")
190        || lower.ends_with(".pkg")
191        || lower.ends_with(".appimage")
192        || lower.ends_with(".bin")
193}
194
195fn hash_ip(ip: &str) -> String {
196    use std::collections::hash_map::DefaultHasher;
197    use std::hash::{Hash, Hasher};
198    let mut hasher = DefaultHasher::new();
199    ip.hash(&mut hasher);
200    format!("{:x}", hasher.finish())
201}
202
203/// Get analytics summary as JSON for the API endpoint.
204pub fn get_summary() -> serde_json::Value {
205    let analytics = get_analytics();
206    let tracker = match analytics.read() {
207        Ok(t) => t,
208        Err(_) => return json!({"error": "lock poisoned"}),
209    };
210
211    let now = Local::now();
212    let today = now.format("%Y-%m-%d").to_string();
213
214    let today_data = build_period_summary(&tracker, &today, 1);
215    let week_data = build_period_summary(&tracker, &today, 7);
216    let month_data = build_period_summary(&tracker, &today, 30);
217
218    let cutoff = (now - TimeDelta::hours(24))
219        .format("%Y-%m-%dT%H:00")
220        .to_string();
221    let hourly: Vec<serde_json::Value> = tracker
222        .hourly
223        .iter()
224        .filter(|b| b.hour >= cutoff)
225        .map(|b| {
226            json!({
227                "hour": b.hour,
228                "views": b.views,
229                "unique": b.unique_ips.len()
230            })
231        })
232        .collect();
233
234    let by_subdomain = build_subdomain_summary(&tracker, &today, 7);
235
236    json!({
237        "today": today_data,
238        "last_7_days": week_data,
239        "last_30_days": month_data,
240        "hourly_traffic": hourly,
241        "by_subdomain": by_subdomain,
242    })
243}
244
245fn build_period_summary(
246    tracker: &AnalyticsTracker,
247    today: &str,
248    days: i64,
249) -> serde_json::Value {
250    let today_date = NaiveDate::parse_from_str(today, "%Y-%m-%d")
251        .unwrap_or_else(|_| Local::now().date_naive());
252
253    let mut total_views = 0u64;
254    let mut total_downloads = 0u64;
255    let mut all_ips: HashSet<String> = HashSet::new();
256    let mut page_totals: HashMap<String, u64> = HashMap::new();
257    let mut download_totals: HashMap<String, u64> = HashMap::new();
258
259    for i in 0..days {
260        let date = (today_date - TimeDelta::days(i))
261            .format("%Y-%m-%d")
262            .to_string();
263        if let Some(day) = tracker.days.get(&date) {
264            total_views += day.total_views;
265            total_downloads += day.total_downloads;
266            all_ips.extend(day.unique_ips.iter().cloned());
267            for (path, count) in &day.page_counts {
268                *page_totals.entry(path.clone()).or_default() += count;
269            }
270            for (file, count) in &day.download_counts {
271                *download_totals.entry(file.clone()).or_default() += count;
272            }
273        }
274    }
275
276    let mut pages: Vec<_> = page_totals.into_iter().collect();
277    pages.sort_by(|a, b| b.1.cmp(&a.1));
278    let top_pages: Vec<serde_json::Value> = pages
279        .into_iter()
280        .take(10)
281        .map(|(path, views)| json!({"path": path, "views": views}))
282        .collect();
283
284    let mut downloads: Vec<_> = download_totals.into_iter().collect();
285    downloads.sort_by(|a, b| b.1.cmp(&a.1));
286    let top_downloads: Vec<serde_json::Value> = downloads
287        .into_iter()
288        .take(10)
289        .map(|(file, count)| json!({"file": file, "count": count}))
290        .collect();
291
292    json!({
293        "page_views": total_views,
294        "unique_visitors": all_ips.len(),
295        "downloads": total_downloads,
296        "top_pages": top_pages,
297        "top_downloads": top_downloads,
298    })
299}
300
301fn build_subdomain_summary(
302    tracker: &AnalyticsTracker,
303    today: &str,
304    days: i64,
305) -> serde_json::Value {
306    let today_date = NaiveDate::parse_from_str(today, "%Y-%m-%d")
307        .unwrap_or_else(|_| Local::now().date_naive());
308
309    let mut views: HashMap<String, u64> = HashMap::new();
310    let mut ips: HashMap<String, HashSet<String>> = HashMap::new();
311
312    for i in 0..days {
313        let date = (today_date - TimeDelta::days(i))
314            .format("%Y-%m-%d")
315            .to_string();
316        if let Some(day) = tracker.days.get(&date) {
317            for (sub, v) in &day.subdomain_views {
318                *views.entry(sub.clone()).or_default() += v;
319            }
320            for (sub, ip_set) in &day.subdomain_ips {
321                ips.entry(sub.clone())
322                    .or_default()
323                    .extend(ip_set.iter().cloned());
324            }
325        }
326    }
327
328    let mut map = serde_json::Map::new();
329    for (sub, v) in &views {
330        let unique = ips.get(sub).map(|s| s.len()).unwrap_or(0);
331        map.insert(sub.clone(), json!({"views": v, "unique": unique}));
332    }
333    serde_json::Value::Object(map)
334}
335
336fn get_analytics_path() -> std::path::PathBuf {
337    crate::core::helpers::get_base_dir()
338        .map(|b| b.join(".rss").join("analytics.json"))
339        .unwrap_or_else(|_| std::path::PathBuf::from(".rss/analytics.json"))
340}
341
342fn save_to_file(tracker: &AnalyticsTracker) -> Result<(), Box<dyn std::error::Error>> {
343    let path = get_analytics_path();
344    if let Some(parent) = path.parent() {
345        std::fs::create_dir_all(parent)?;
346    }
347    let json = serde_json::to_string(tracker)?;
348    std::fs::write(&path, json)?;
349    log::debug!("Analytics saved to {:?}", path);
350    Ok(())
351}
352
353fn load_from_file() -> Option<AnalyticsTracker> {
354    let path = get_analytics_path();
355    let content = std::fs::read_to_string(&path).ok()?;
356    serde_json::from_str(&content).ok()
357}
358
359/// Save analytics to disk. Called during shutdown.
360pub fn save_analytics_on_shutdown() {
361    if let Some(analytics) = ANALYTICS.get() {
362        if let Ok(mut tracker) = analytics.write() {
363            prune_old_data(&mut tracker);
364            if let Err(e) = save_to_file(&tracker) {
365                log::error!("Failed to save analytics on shutdown: {}", e);
366            } else {
367                log::info!("Analytics saved on shutdown");
368            }
369        }
370    }
371}
372
373fn prune_old_data(tracker: &mut AnalyticsTracker) {
374    let cutoff = (Local::now() - TimeDelta::days(60))
375        .format("%Y-%m-%d")
376        .to_string();
377    tracker
378        .days
379        .retain(|date, _| date.as_str() >= cutoff.as_str());
380}
381
382/// Dashboard HTML template. The placeholder `__ANALYTICS_DATA__` is replaced
383/// with the current analytics JSON at render time.
384pub const DASHBOARD_HTML: &str = r#"<!DOCTYPE html>
385<html lang="en">
386<head>
387<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
388<title>Analytics - Rush Sync Server</title>
389<link rel="icon" href="/.rss/favicon.svg" type="image/svg+xml">
390<style>
391*{margin:0;padding:0;box-sizing:border-box}
392body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e4e4ef;min-height:100vh}
393.container{max-width:1200px;margin:0 auto;padding:24px}
394.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:32px;flex-wrap:wrap;gap:12px}
395.header h1{font-size:26px;font-weight:700;letter-spacing:-0.5px}
396.header h1 span{color:#6c63ff}
397.header-right{display:flex;align-items:center;gap:16px}
398.live-dot{width:8px;height:8px;border-radius:50%;background:#4ade80;animation:pulse 2s ease-in-out infinite}
399@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(74,222,128,0.4)}50%{box-shadow:0 0 0 6px rgba(74,222,128,0)}}
400.live-label{font-size:12px;color:#4ade80;display:flex;align-items:center;gap:6px}
401.back{color:#6c63ff;text-decoration:none;font-size:14px}
402.back:hover{text-decoration:underline}
403.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}
404.card{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:20px;transition:border-color 0.2s}
405.card:hover{border-color:#3a3a4a}
406.card .lbl{font-size:11px;color:#8888a0;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
407.card .val{font-size:32px;font-weight:700;line-height:1.1}
408.card .sub{font-size:12px;color:#555;margin-top:6px}
409.card .val.purple{color:#6c63ff}
410.card .val.green{color:#4ade80}
411.card .val.blue{color:#22d3ee}
412.card .val.orange{color:#fb923c}
413.tabs{display:flex;gap:8px;margin-bottom:20px}
414.tab{padding:8px 18px;border-radius:8px;background:#14141f;border:1px solid #2a2a3a;color:#8888a0;cursor:pointer;font-size:13px;font-weight:500;transition:all 0.2s}
415.tab:hover{border-color:#6c63ff;color:#c0c0d0}
416.tab.active{background:#6c63ff;color:#fff;border-color:#6c63ff}
417.section{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:24px;margin-bottom:16px}
418.section h2{font-size:14px;margin-bottom:16px;font-weight:600;color:#8888a0;text-transform:uppercase;letter-spacing:0.5px}
419.chart{display:flex;align-items:flex-end;gap:3px;height:160px;padding:0 0 28px 0;position:relative}
420.bar-w{flex:1;display:flex;flex-direction:column;align-items:center;position:relative;min-width:0}
421.bar{width:100%;background:linear-gradient(180deg,#6c63ff,#4a43cc);border-radius:4px 4px 0 0;min-height:1px;transition:height 0.4s ease;cursor:pointer}
422.bar:hover{background:linear-gradient(180deg,#8b83ff,#6c63ff)}
423.bar-lbl{font-size:9px;color:#555566;position:absolute;bottom:-22px;white-space:nowrap}
424.tooltip{display:none;position:absolute;top:-44px;left:50%;transform:translateX(-50%);background:#2a2a3a;color:#e4e4ef;padding:6px 10px;border-radius:6px;font-size:11px;white-space:nowrap;z-index:10;box-shadow:0 4px 12px rgba(0,0,0,0.3)}
425.tooltip::after{content:'';position:absolute;bottom:-4px;left:50%;transform:translateX(-50%);border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #2a2a3a}
426.bar-w:hover .tooltip{display:block}
427.chart-empty{display:flex;align-items:flex-end;gap:3px;height:160px;padding-bottom:28px}
428.chart-empty .bar-w{flex:1}.chart-empty .bar{background:#1a1a2a;height:20%;border-radius:4px 4px 0 0}
429.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
430@media(max-width:768px){.grid{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}.chart{height:120px}}
431table{width:100%;border-collapse:collapse}
432th{text-align:left;font-size:10px;color:#555566;text-transform:uppercase;letter-spacing:1px;padding:8px 0;border-bottom:1px solid #2a2a3a}
433td{padding:10px 0;font-size:13px;border-bottom:1px solid #1a1a2a}
434td:last-child{text-align:right;font-weight:600;color:#6c63ff;font-family:'SF Mono',monospace;font-size:12px}
435.path-cell{font-family:'SF Mono',monospace;font-size:12px;color:#c0c0d0;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
436.sub-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
437.sub-card{background:#1a1a2a;border:1px solid #252535;border-radius:10px;padding:16px;transition:border-color 0.2s}
438.sub-card:hover{border-color:#3a3a4a}
439.sub-card .name{font-weight:600;margin-bottom:6px;color:#e4e4ef;font-size:14px}
440.sub-card .stats{font-size:12px;color:#8888a0;line-height:1.5}
441.sub-card .stats span{color:#6c63ff;font-weight:600}
442.empty{color:#555;font-style:italic;font-size:13px;padding:20px;text-align:center}
443.footer{text-align:center;font-size:11px;color:#444;padding:20px;display:flex;justify-content:center;align-items:center;gap:8px}
444.footer .refresh-dot{width:6px;height:6px;border-radius:50%;background:#333;animation:none}
445.footer .refresh-dot.active{background:#4ade80;animation:pulse 2s ease-in-out infinite}
446</style>
447</head>
448<body>
449<div class="container">
450<div class="header">
451<h1>Analytics <span>Dashboard</span></h1>
452<div class="header-right">
453<div class="live-label"><div class="live-dot"></div>Live</div>
454<a href="/" class="back">&larr; Back to site</a>
455</div>
456</div>
457<div class="cards" id="cards"></div>
458<div class="tabs" id="tabs">
459<div class="tab active" data-p="today">Today</div>
460<div class="tab" data-p="last_7_days">7 Days</div>
461<div class="tab" data-p="last_30_days">30 Days</div>
462</div>
463<div class="section"><h2>Hourly Traffic (Last 24h)</h2><div class="chart" id="chart"></div></div>
464<div class="grid">
465<div class="section"><h2>Top Pages</h2><div id="pages"></div></div>
466<div class="section"><h2>Downloads</h2><div id="downloads"></div></div>
467</div>
468<div class="section"><h2>Traffic by Subdomain</h2><div class="sub-grid" id="subs"></div></div>
469<div class="footer"><div class="refresh-dot active" id="rdot"></div><span id="foot">Loading...</span></div>
470</div>
471<script>
472var D=__ANALYTICS_DATA__;
473var P='today';
474var refreshTimer=30;
475document.querySelectorAll('.tab').forEach(function(t){t.addEventListener('click',function(){document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});t.classList.add('active');P=t.dataset.p;render()})});
476function render(){var p=D[P]||D.today||{};
477var views=p.page_views||0;var uniq=p.unique_visitors||0;var dls=p.downloads||0;
478var vpu=uniq>0?Math.round(views/uniq):0;
479document.getElementById('cards').innerHTML=
480'<div class="card"><div class="lbl">Page Views</div><div class="val purple">'+fmt(views)+'</div><div class="sub">Total tracked requests</div></div>'+
481'<div class="card"><div class="lbl">Unique Visitors</div><div class="val green">'+fmt(uniq)+'</div><div class="sub">By unique IP address</div></div>'+
482'<div class="card"><div class="lbl">Downloads</div><div class="val blue">'+fmt(dls)+'</div><div class="sub">Binary downloads</div></div>'+
483'<div class="card"><div class="lbl">Views / Visitor</div><div class="val orange">'+fmt(vpu)+'</div><div class="sub">Avg. engagement</div></div>';
484renderChart();renderPages(p);renderDownloads(p);renderSubs()}
485function renderChart(){var h=D.hourly_traffic||[];
486var el=document.getElementById('chart');
487if(h.length===0){el.innerHTML='<div class="empty">No hourly data yet</div>';return}
488var allHours=[];var hmap={};
489h.forEach(function(x){var hr=x.hour;hmap[hr]=x});
490var now=new Date();for(var i=23;i>=0;i--){var d=new Date(now.getTime()-i*3600000);var key=d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'T'+pad(d.getHours())+':00';allHours.push(key)}
491var mx=1;allHours.forEach(function(k){var v=hmap[k];if(v&&v.views>mx)mx=v.views});
492el.innerHTML=allHours.map(function(k){var v=hmap[k]||{views:0,unique:0};var pct=mx>0?(v.views/mx)*100:0;var hr=k.split('T')[1].replace(':00','h');var barH=v.views>0?Math.max(pct,3):0;return '<div class="bar-w"><div class="bar" style="height:'+barH+'%"></div><div class="tooltip">'+fmt(v.views)+' views &middot; '+fmt(v.unique)+' unique</div><div class="bar-lbl">'+hr+'</div></div>'}).join('')}
493function renderPages(p){var pg=p.top_pages||[];var el=document.getElementById('pages');
494if(pg.length===0){el.innerHTML='<div class="empty">No page views yet</div>';return}
495el.innerHTML='<table><tr><th>Path</th><th>Views</th></tr>'+pg.map(function(x){return '<tr><td class="path-cell" title="'+esc(x.path)+'">'+esc(x.path)+'</td><td>'+fmt(x.views)+'</td></tr>'}).join('')+'</table>'}
496function renderDownloads(p){var dl=p.top_downloads||[];var el=document.getElementById('downloads');
497if(dl.length===0){el.innerHTML='<div class="empty">No downloads tracked yet</div>';return}
498el.innerHTML='<table><tr><th>File</th><th>Count</th></tr>'+dl.map(function(x){var name=x.file.split('/').pop();return '<tr><td class="path-cell" title="'+esc(x.file)+'">'+esc(name)+'</td><td>'+fmt(x.count)+'</td></tr>'}).join('')+'</table>'}
499function renderSubs(){var sb=D.by_subdomain||{};var sk=Object.keys(sb);var el=document.getElementById('subs');
500if(sk.length===0){el.innerHTML='<div class="empty">No subdomain data</div>';return}
501sk.sort(function(a,b){return (sb[b].views||0)-(sb[a].views||0)});
502el.innerHTML=sk.map(function(s){var d=sb[s];return '<div class="sub-card"><div class="name">'+esc(s)+'</div><div class="stats"><span>'+fmt(d.views)+'</span> views &middot; <span>'+fmt(d.unique)+'</span> unique visitors</div></div>'}).join('')}
503function fmt(n){return (n||0).toLocaleString()}
504function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
505function pad(n){return n<10?'0'+n:''+n}
506render();
507setInterval(function(){refreshTimer--;if(refreshTimer<=0){location.reload()}document.getElementById('foot').textContent='Updated '+new Date().toLocaleTimeString()+' \u00b7 refresh in '+refreshTimer+'s'},1000);
508document.getElementById('foot').textContent='Updated '+new Date().toLocaleTimeString()+' \u00b7 refresh in 30s';
509</script>
510</body></html>"#;
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_is_trackable_filters_health() {
518        assert!(!is_trackable_request("/api/health", "Mozilla/5.0"));
519        assert!(!is_trackable_request("/api/status", "Mozilla/5.0"));
520        assert!(!is_trackable_request("/api/metrics", "Mozilla/5.0"));
521    }
522
523    #[test]
524    fn test_is_trackable_filters_internal() {
525        assert!(!is_trackable_request("/.rss/style.css", "Mozilla/5.0"));
526        assert!(!is_trackable_request("/rss.js", "Mozilla/5.0"));
527        assert!(!is_trackable_request("/ws/hot-reload", "Mozilla/5.0"));
528        assert!(!is_trackable_request("/.well-known/acme-challenge/xxx", "Mozilla/5.0"));
529    }
530
531    #[test]
532    fn test_is_trackable_filters_bots() {
533        assert!(!is_trackable_request("/", "Googlebot/2.1"));
534        assert!(!is_trackable_request("/", "curl/7.68.0"));
535        assert!(!is_trackable_request("/", "Python-requests/2.28"));
536    }
537
538    #[test]
539    fn test_is_trackable_allows_real_requests() {
540        assert!(is_trackable_request("/", "Mozilla/5.0 (Macintosh)"));
541        assert!(is_trackable_request("/docs", "Mozilla/5.0"));
542        assert!(is_trackable_request("/about", "Safari/537.36"));
543    }
544
545    #[test]
546    fn test_is_download() {
547        assert!(is_download("/releases/app.zip"));
548        assert!(is_download("/releases/app.tar.gz"));
549        assert!(is_download("/releases/app.exe"));
550        assert!(is_download("/releases/app.dmg"));
551        assert!(is_download("/releases/app.AppImage"));
552        assert!(is_download("/downloads/rush-sync-linux-amd64.bin"));
553        assert!(!is_download("/index.html"));
554        assert!(!is_download("/api/status"));
555    }
556
557    #[test]
558    fn test_hash_ip_deterministic() {
559        let h1 = hash_ip("192.168.1.1");
560        let h2 = hash_ip("192.168.1.1");
561        assert_eq!(h1, h2);
562        assert_ne!(hash_ip("192.168.1.1"), hash_ip("10.0.0.1"));
563    }
564}