use askama::Template;
use askama_web::WebTemplate;
use axum::{extract::State, response::IntoResponse};
use std::time::Duration;
use crate::{
resolver::upstream::UpstreamHealthRow,
storage::query_log::{QueryLogCounts, QueryLogRepository},
telemetry::StatsSnapshot,
time::{self, Clock},
web::{AppState, Chrome, auth::CurrentUser, render::DomainDisplay},
};
const TOP_N: usize = 10;
const WINDOW: Duration = time::days(1);
impl AppState {
pub async fn dashboard(user: CurrentUser, State(state): State<AppState>) -> impl IntoResponse {
let snapshot = state.telemetry.stats.snapshot(TOP_N);
let blocklist_size = state.resolver.blocklist().len();
let repo = state.db.query_log();
let since = Clock::millis_ago(WINDOW);
let window = WindowStats {
counts: repo.counts_since(since).await.unwrap_or_default(),
top_domains: repo
.top_domains_since(since, TOP_N as i64)
.await
.unwrap_or_default(),
top_clients: repo
.top_clients_since(since, TOP_N as i64)
.await
.unwrap_or_default(),
};
let upstreams = state
.upstream_pool
.health()
.snapshot()
.into_iter()
.map(UpstreamRow::from)
.collect();
let system = SystemInfo::capture(&state);
let mut top_clients = Vec::with_capacity(snapshot.top_clients.len());
for (ip, count) in &snapshot.top_clients {
top_clients.push((state.client_label_ip(*ip).await, group(*count)));
}
let mut window_top_clients = Vec::with_capacity(window.top_clients.len());
for (ip, count) in &window.top_clients {
window_top_clients.push((state.client_label(ip).await, group((*count).max(0) as u64)));
}
DashboardTemplate::new(
state.chrome("dashboard", &user).await,
snapshot,
blocklist_size,
window,
top_clients,
window_top_clients,
upstreams,
system,
)
}
}
struct SystemInfo {
version: &'static str,
uptime_secs: i64,
uptime: String,
cache_entries: String,
cache_capacity: String,
process_memory: String,
}
impl SystemInfo {
fn capture(state: &AppState) -> Self {
let uptime_secs = state.started_at.elapsed().as_secs() as i64;
Self {
version: env!("CARGO_PKG_VERSION"),
uptime_secs,
uptime: humanize_uptime(uptime_secs),
cache_entries: group(state.resolver.cache().entry_count()),
cache_capacity: group(state.resolver.settings().cache_capacity),
process_memory: process_rss_bytes()
.map(format_mib)
.unwrap_or_else(|| "—".to_owned()),
}
}
}
fn process_rss_bytes() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: u64 = status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))?
.split_whitespace()
.next()?
.parse()
.ok()?;
Some(kb * 1024)
}
fn format_mib(bytes: u64) -> String {
format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0))
}
fn humanize_uptime(secs: i64) -> String {
let secs = secs.max(0);
let days = secs / 86_400;
let hours = (secs % 86_400) / 3_600;
let mins = (secs % 3_600) / 60;
format!("{days}d {hours}h {mins}m")
}
struct UpstreamRow {
addr: String,
queries: String,
success_rate: String,
latency: String,
last_error: String,
}
impl From<UpstreamHealthRow> for UpstreamRow {
fn from(row: UpstreamHealthRow) -> Self {
Self {
addr: row.addr.to_string(),
queries: group(row.attempts()),
success_rate: format!("{:.1}%", row.success_rate * 100.0),
latency: row
.ewma_latency_ms
.map(|ms| format!("{ms:.1} ms"))
.unwrap_or_else(|| "—".to_owned()),
last_error: row.last_error.unwrap_or_default(),
}
}
}
struct WindowStats {
counts: QueryLogCounts,
top_domains: Vec<(String, i64)>,
top_clients: Vec<(String, i64)>,
}
#[derive(Template, WebTemplate)]
#[template(path = "dashboard.html")]
struct DashboardTemplate {
chrome: Chrome,
total: u64,
blocked: u64,
cached: u64,
forwarded: u64,
blocklist_size: String,
top_domains: Vec<(String, String)>,
top_clients: Vec<(String, String)>,
window_total: String,
window_blocked: String,
window_cached: String,
window_forwarded: String,
window_top_domains: Vec<(String, String)>,
window_top_clients: Vec<(String, String)>,
upstreams: Vec<UpstreamRow>,
system: SystemInfo,
}
impl DashboardTemplate {
#[allow(clippy::too_many_arguments)]
fn new(
chrome: Chrome,
snap: StatsSnapshot,
blocklist_size: usize,
window: WindowStats,
top_clients: Vec<(String, String)>,
window_top_clients: Vec<(String, String)>,
upstreams: Vec<UpstreamRow>,
system: SystemInfo,
) -> Self {
Self {
chrome,
upstreams,
system,
total: snap.total,
blocked: snap.blocked,
cached: snap.cached,
forwarded: snap.forwarded,
blocklist_size: group(blocklist_size as u64),
top_domains: snap
.top_domains
.into_iter()
.map(|(d, c)| (d.display_domain().to_owned(), group(c)))
.collect(),
top_clients,
window_total: group(window.counts.total.max(0) as u64),
window_blocked: group(window.counts.blocked.max(0) as u64),
window_cached: group(window.counts.cached.max(0) as u64),
window_forwarded: group(window.counts.forwarded.max(0) as u64),
window_top_domains: window
.top_domains
.into_iter()
.map(|(d, c)| (d.display_domain().to_owned(), group(c.max(0) as u64)))
.collect(),
window_top_clients,
}
}
}
pub(crate) fn group(n: u64) -> String {
let digits = n.to_string();
let len = digits.len();
let mut out = String::with_capacity(len + len / 3);
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn group_inserts_thousands_separators() {
assert_eq!(group(0), "0");
assert_eq!(group(42), "42");
assert_eq!(group(1_000), "1,000");
assert_eq!(group(12_403), "12,403");
assert_eq!(group(1_234_567), "1,234,567");
}
fn test_chrome() -> Chrome {
Chrome {
theme: "auto".to_owned(),
active: "dashboard",
show_nav: true,
authenticated: true,
csrf_token: "tok".to_owned(),
pause_remaining: None,
asset_version: "test",
}
}
fn test_system() -> SystemInfo {
SystemInfo {
version: "9.9.9",
uptime_secs: 90_061,
uptime: humanize_uptime(90_061),
cache_entries: group(8_123),
cache_capacity: group(100_000),
process_memory: "14.2 MiB".to_owned(),
}
}
#[test]
fn template_seeds_signals_and_tables() {
let snap = StatsSnapshot {
total: 1000,
blocked: 382,
cached: 100,
forwarded: 518,
blocked_ratio: 0.382,
top_domains: vec![("ads.example.com.".to_owned(), 50)],
top_clients: vec![("192.168.1.10".parse().unwrap(), 120)],
};
let window = WindowStats {
counts: QueryLogCounts {
total: 2400,
blocked: 900,
cached: 500,
forwarded: 1000,
},
top_domains: vec![("win.example.com.".to_owned(), 77)],
top_clients: vec![("10.9.8.7".to_owned(), 64)],
};
let html = DashboardTemplate::new(
test_chrome(),
snap,
65432,
window,
vec![("192.168.1.10".to_owned(), "120".to_owned())],
vec![("10.9.8.7".to_owned(), "64".to_owned())],
vec![],
test_system(),
)
.render()
.expect("render");
assert!(html.contains("queries: 1000"));
assert!(html.contains("blocked: 382"));
assert!(html.contains("65,432"));
assert!(html.contains("ads.example.com"));
assert!(!html.contains("ads.example.com."));
assert!(html.contains("192.168.1.10"));
assert!(html.contains("Last 24 hours (persisted)"));
assert!(html.contains("2,400"));
assert!(html.contains("win.example.com"));
assert!(!html.contains("win.example.com."));
assert!(html.contains("10.9.8.7"));
}
#[test]
fn template_empty_window_renders_zeros_without_panic() {
let snap = StatsSnapshot {
total: 0,
blocked: 0,
cached: 0,
forwarded: 0,
blocked_ratio: 0.0,
top_domains: vec![],
top_clients: vec![],
};
let window = WindowStats {
counts: QueryLogCounts::default(),
top_domains: vec![],
top_clients: vec![],
};
let html = DashboardTemplate::new(
test_chrome(),
snap,
0,
window,
vec![],
vec![],
vec![],
test_system(),
)
.render()
.expect("render");
assert!(html.contains("No queries in the last 24 hours."));
}
#[test]
fn upstream_health_table_renders() {
let snap = StatsSnapshot {
total: 0,
blocked: 0,
cached: 0,
forwarded: 0,
blocked_ratio: 0.0,
top_domains: vec![],
top_clients: vec![],
};
let window = WindowStats {
counts: QueryLogCounts::default(),
top_domains: vec![],
top_clients: vec![],
};
let rows = vec![
UpstreamRow::from(UpstreamHealthRow {
addr: "1.1.1.1:53".parse().unwrap(),
successes: 99,
failures: 1,
success_rate: 0.99,
ewma_latency_ms: Some(12.34),
last_error: None,
}),
UpstreamRow::from(UpstreamHealthRow {
addr: "9.9.9.9:53".parse().unwrap(),
successes: 0,
failures: 3,
success_rate: 0.0,
ewma_latency_ms: None,
last_error: Some("upstream UDP query timed out".to_owned()),
}),
];
let html = DashboardTemplate::new(
test_chrome(),
snap,
0,
window,
vec![],
vec![],
rows,
test_system(),
)
.render()
.expect("render");
assert!(html.contains("1.1.1.1:53"));
assert!(
html.contains("99.0%"),
"success rate is formatted as a percent"
);
assert!(html.contains("12.3 ms"), "latency EWMA is shown in ms");
assert!(html.contains("9.9.9.9:53"));
assert!(html.contains("0.0%"));
assert!(html.contains("upstream UDP query timed out"));
}
#[test]
fn system_panel_renders() {
let snap = StatsSnapshot {
total: 0,
blocked: 0,
cached: 0,
forwarded: 0,
blocked_ratio: 0.0,
top_domains: vec![],
top_clients: vec![],
};
let window = WindowStats {
counts: QueryLogCounts::default(),
top_domains: vec![],
top_clients: vec![],
};
let html = DashboardTemplate::new(
test_chrome(),
snap,
0,
window,
vec![],
vec![],
vec![],
test_system(),
)
.render()
.expect("render");
assert!(html.contains("System"));
assert!(html.contains("9.9.9"), "version shown");
assert!(html.contains("1d 1h 1m"), "uptime humanized");
assert!(html.contains("8,123 / 100,000"), "cache fill shown");
assert!(html.contains("14.2 MiB"), "process memory shown");
assert!(html.contains("data-on-interval"));
}
#[test]
fn humanize_uptime_formats_days_hours_minutes() {
assert_eq!(humanize_uptime(0), "0d 0h 0m");
assert_eq!(humanize_uptime(90_061), "1d 1h 1m");
assert_eq!(humanize_uptime(-5), "0d 0h 0m");
}
#[test]
fn format_mib_rounds_to_one_decimal() {
assert_eq!(format_mib(14_889_779), "14.2 MiB");
assert_eq!(format_mib(0), "0.0 MiB");
}
}