Skip to main content

scx_flow/rust/
webui.rs

1// SPDX-License-Identifier: GPL-2.0
2//! Loopback dashboard for the flow scheduler.
3//!
4//! Copyright (c) 2026 Galih Tama <galpt@v.recipes>
5
6//! Serves the embedded page plus the live snapshot as JSON on loopback.
7//! Uses tiny_http 0.12 pinned in Cargo.toml with loopback only plus no
8//! TLS plus no store. Binds IPv6 loopback first with IPv4 fallback and
9//! serves no WAN route, so the page plus JSON never leave the host.
10
11use std::sync::Arc;
12use std::sync::Mutex;
13use std::sync::atomic::AtomicBool;
14use std::sync::atomic::Ordering;
15use std::time::Duration;
16
17use crossbeam::channel::Receiver;
18use serde::Serialize;
19use serde_json::Value;
20use serde_json::json;
21use tiny_http::Header;
22use tiny_http::Response;
23use tiny_http::Server;
24
25use crate::stats::WebMetrics;
26
27/* Loopback TCP port of the dashboard. */
28const PORT: u16 = 50005;
29/* Poll bound of the snapshot channel. */
30const POLL: Duration = Duration::from_millis(200);
31/* JSON content type value. */
32const JSON: &str = "application/json";
33/* HTML content type value. */
34const HTML: &str = "text/html";
35
36/* Newest snapshot behind a lock for the handlers. */
37struct WebState {
38    metrics: WebMetrics,
39}
40
41/* JSON value of a serializable item. */
42fn jv<T: Serialize>(v: &T) -> Value {
43    serde_json::to_value(v).unwrap_or_default()
44}
45
46/* JSON text of a value with an empty object on failure. */
47fn jt(v: &Value) -> String {
48    serde_json::to_string(v).unwrap_or("{}".into())
49}
50
51/* Merged dashboard object for one snapshot. */
52/* Full log with version plus timestamp plus topology plus stats plus */
53/* per CPU. Same object serves stats polling plus snapshot download on */
54/* loopback with no new exposure. */
55fn merged(snap: &WebMetrics) -> Value {
56    json!({
57        "version": snap.version.clone(),
58        "timestamp_ns": snap.timestamp_ns,
59        "topology": snap.topology.clone(),
60        "stats": jv(&snap.stats),
61        "per_cpu": jv(&snap.per_cpu),
62    })
63}
64
65/* Start the dashboard thread. */
66/* Consumes snapshots plus exits when the shutdown flag is set */
67/* or the channel closes. Serves the page on the root plus the */
68/* same JSON on the stats plus snapshot paths with loopback only */
69/* plus no store plus unknown paths get not found. Binds one */
70/* loopback only with IPv6 first plus IPv4 fallback plus no */
71/* serve when both fail. One thread plus one lock per poll */
72/* stays cheap beside the page poll with no backlog. */
73pub fn start(rx: Receiver<WebMetrics>, shutdown: Arc<AtomicBool>) {
74    log::info!("web thread started");
75    let html = include_str!("../../ui/index.html").to_string();
76    let state = Arc::new(Mutex::new(WebState {
77        metrics: WebMetrics::default(),
78    }));
79    let keep = state.clone();
80    let done = shutdown.clone();
81    std::thread::spawn(move || {
82        while !done.load(Ordering::Relaxed) {
83            match rx.recv_timeout(POLL) {
84                Ok(m) => {
85                    if let Ok(mut s) = keep.lock() {
86                        s.metrics = m;
87                    }
88                }
89                Err(crossbeam::channel::RecvTimeoutError::Timeout) => {}
90                Err(_) => break,
91            }
92        }
93    });
94    let mut server: Option<Server> = None;
95    let mut addr = String::new();
96    if let Ok(s) = Server::http(format!("[::1]:{PORT}")) {
97        addr = format!("[::1]:{PORT}");
98        server = Some(s);
99    }
100    if server.is_none()
101        && let Ok(s) = Server::http(format!("127.0.0.1:{PORT}"))
102    {
103        addr = format!("127.0.0.1:{PORT}");
104        server = Some(s);
105    }
106    let Some(server) = server else {
107        log::warn!("web TCP blocked with no serve");
108        return;
109    };
110    log::info!("web on port {addr}");
111    let nocache = Header::from_bytes("Cache-Control", "no-store").unwrap();
112    let nosniff = Header::from_bytes("X-Content-Type-Options", "nosniff").unwrap();
113    let frame = Header::from_bytes("X-Frame-Options", "DENY").unwrap();
114    let htype = Header::from_bytes("Content-Type", HTML).unwrap();
115    let jtype = Header::from_bytes("Content-Type", JSON).unwrap();
116    while !shutdown.load(Ordering::Relaxed) {
117        let got = server.recv_timeout(Duration::from_millis(200));
118        let req = match got {
119            Ok(Some(v)) => v,
120            _ => continue,
121        };
122        let snap = match state.lock() {
123            Ok(v) => v.metrics.clone(),
124            Err(_) => continue,
125        };
126        match req.url() {
127            "/" => {
128                let resp = Response::from_string(&html);
129                let resp = resp.with_header(htype.clone());
130                let resp = resp.with_header(nocache.clone());
131                let resp = resp.with_header(nosniff.clone());
132                let resp = resp.with_header(frame.clone());
133                let _ = req.respond(resp);
134            }
135            "/api/stats" | "/api/snapshot" => {
136                let txt = jt(&merged(&snap));
137                let resp = Response::from_string(txt);
138                let resp = resp.with_header(jtype.clone());
139                let resp = resp.with_header(nocache.clone());
140                let resp = resp.with_header(nosniff.clone());
141                let resp = resp.with_header(frame.clone());
142                let _ = req.respond(resp);
143            }
144            _ => {
145                let resp = Response::empty(404)
146                    .with_header(nocache.clone())
147                    .with_header(nosniff.clone())
148                    .with_header(frame.clone());
149                let _ = req.respond(resp);
150            }
151        }
152    }
153    log::info!("web stopped");
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    /* Merged keeps the five live keys with no stale keys. */
161    #[test]
162    fn merged_keeps_live_keys() {
163        let snap = WebMetrics::default();
164        let v = merged(&snap);
165        assert!(v.get("stats").is_some());
166        assert!(v.get("per_cpu").is_some());
167        assert!(v.get("version").is_some());
168        assert!(v.get("timestamp_ns").is_some());
169        assert!(v.get("topology").is_some());
170        assert_eq!(v.as_object().map(|o| o.len()), Some(5));
171        assert!(v.get("governor").is_none());
172        assert!(v.get("energy").is_none());
173        assert!(v.get("ml_accuracy").is_none());
174        assert!(v.get("ml_hits").is_none());
175    }
176
177    /* Old snapshots without new fields still decode. */
178    #[test]
179    fn web_metrics_missing_fields_default() {
180        let txt = "{\"stats\":{\"on_cpu\":1},\"version\":\"4.7.0\"}";
181        let m: WebMetrics = serde_json::from_str(txt).unwrap();
182        assert_eq!(m.stats.on_cpu, 1);
183        assert_eq!(m.stats.local_moves, 0);
184        assert_eq!(m.stats.gate_rejects, 0);
185        assert_eq!(m.stats.preempt_kicks, 0);
186        assert_eq!(m.stats.preempt_skipped, 0);
187        assert_eq!(m.stats.red_rejects, 0);
188        assert_eq!(m.stats.red_reclaims, 0);
189        assert!(m.per_cpu.is_empty());
190        assert_eq!(m.version, "4.7.0");
191        assert_eq!(m.timestamp_ns, 0);
192        assert_eq!(m.topology, "");
193        let old = "{\"id\":1,\"running_pid\":5,\"slice_ns\":2000000}";
194        let card: crate::stats::PerCpuMetrics = serde_json::from_str(old).unwrap();
195        assert_eq!(card.id, 1);
196        assert!(!card.smt);
197        assert_eq!(card.running_pid, 5);
198        let v = merged(&m);
199        assert_eq!(v.as_object().map(|o| o.len()), Some(5));
200        let back: WebMetrics = serde_json::from_value(v).unwrap();
201        assert_eq!(back.stats.on_cpu, 1);
202        assert_eq!(back.version, "4.7.0");
203    }
204
205    /* Full snapshot round trips through JSON with live counters. */
206    #[test]
207    fn web_metrics_round_trip() {
208        let snap = WebMetrics {
209            stats: crate::stats::Metrics {
210                on_cpu: 2,
211                total_runtime: 1_000_000,
212                uptime_ns: 2_000_000,
213                inserts: 3,
214                requeues: 1,
215                completions: 2,
216                local_moves: 10,
217                node_moves: 4,
218                machine_moves: 2,
219                kicks: 5,
220                admits: 3,
221                rejects: 1,
222                misses: 2,
223                gate_rejects: 0,
224                preempt_kicks: 1,
225                preempt_skipped: 2,
226                red_rejects: 1,
227                red_reclaims: 1,
228            },
229            per_cpu: vec![
230                crate::stats::PerCpuMetrics {
231                    id: 0,
232                    smt: false,
233                    running_pid: 7,
234                    slice_ns: 1_000_000,
235                },
236                crate::stats::PerCpuMetrics {
237                    id: 1,
238                    smt: true,
239                    running_pid: 0,
240                    slice_ns: 1_000_000,
241                },
242            ],
243            version: "4.7.0".to_string(),
244            timestamp_ns: 1_700_000_000_000_000_000,
245            topology: "cpus=4 seeded".to_string(),
246        };
247        let txt = serde_json::to_string(&snap).unwrap();
248        assert!(txt.contains("local_moves"));
249        assert!(txt.contains("node_moves"));
250        assert!(txt.contains("machine_moves"));
251        assert!(!txt.contains("over_moves"));
252        assert!(txt.contains("admits"));
253        assert!(txt.contains("rejects"));
254        assert!(txt.contains("misses"));
255        assert!(!txt.contains("\"parks\""));
256        assert!(txt.contains("gate_rejects"));
257        assert!(txt.contains("preempt_kicks"));
258        assert!(txt.contains("preempt_skipped"));
259        assert!(txt.contains("red_rejects"));
260        assert!(txt.contains("red_reclaims"));
261        assert!(!txt.contains("ml_hits"));
262        assert!(!txt.contains("ml_accuracy"));
263        assert!(txt.contains("slice_ns"));
264        assert!(txt.contains("running_pid"));
265        assert!(txt.contains("\"smt\":false"));
266        assert!(txt.contains("\"smt\":true"));
267        assert!(txt.contains("version"));
268        assert!(txt.contains("topology"));
269        assert!(txt.contains("timestamp_ns"));
270        assert!(!txt.contains("steal_moves"));
271        assert!(!txt.contains("slot_moves"));
272        assert!(!txt.contains("throttled_ns"));
273        assert!(!txt.contains("nr_throttled"));
274        assert!(!txt.contains("bw_moves"));
275        assert!(!txt.contains("park_moves"));
276        assert!(!txt.contains("enq_no_tctx"));
277        assert!(!txt.contains("freq_khz"));
278        assert!(!txt.contains("cur_freq"));
279        assert!(!txt.contains("llc_id"));
280        assert!(!txt.contains("governor"));
281        assert!(!txt.contains("energy"));
282        let back: WebMetrics = serde_json::from_str(&txt).unwrap();
283        assert_eq!(back.stats.local_moves, 10);
284        assert_eq!(back.stats.node_moves, 4);
285        assert_eq!(back.stats.machine_moves, 2);
286        assert_eq!(back.stats.admits, 3);
287        assert_eq!(back.stats.rejects, 1);
288        assert_eq!(back.stats.misses, 2);
289        assert_eq!(back.stats.gate_rejects, 0);
290        assert_eq!(back.stats.preempt_kicks, 1);
291        assert_eq!(back.stats.preempt_skipped, 2);
292        assert_eq!(back.stats.red_rejects, 1);
293        assert_eq!(back.stats.red_reclaims, 1);
294        assert_eq!(back.per_cpu.len(), 2);
295        assert!(!back.per_cpu[0].smt);
296        assert!(back.per_cpu[1].smt);
297        assert_eq!(back.per_cpu[0].running_pid, 7);
298        assert_eq!(back.per_cpu[0].slice_ns, 1_000_000);
299        assert_eq!(back.per_cpu[1].id, 1);
300        assert_eq!(back.version, "4.7.0");
301        assert_eq!(back.topology, "cpus=4 seeded");
302        let v = merged(&snap);
303        assert_eq!(v.as_object().map(|o| o.len()), Some(5));
304        assert!(v.get("ml_accuracy").is_none());
305        assert!(v.get("governor").is_none());
306        assert!(v.get("energy").is_none());
307    }
308
309    /* Dashboard keeps live ids without stale cards. */
310    #[test]
311    fn dashboard_keeps_live_layout() {
312        let html = include_str!("../../ui/index.html");
313        assert!(!html.contains("stale-pill"));
314        assert!(!html.contains("core-stale"));
315        assert!(html.contains("text-overflow: ellipsis"));
316        assert!(html.contains("tabular-nums"));
317    }
318
319    /* Download keeps version plus timestamp in the file name. */
320    #[test]
321    fn dashboard_download_names_versioned_file() {
322        let html = include_str!("../../ui/index.html");
323        assert!(html.contains("downloadSnapshot"));
324        assert!(html.contains("JSON.stringify(data, null, 2)"));
325        assert!(html.contains("data.version"));
326        assert!(html.contains("data.timestamp_ns"));
327        assert!(html.contains("scx_flow_"));
328        assert!(html.contains("/api/snapshot"));
329    }
330
331    /* Dashboard shows the sixteen live counters plus uptime. */
332    #[test]
333    fn dashboard_shows_live_counters() {
334        let html = include_str!("../../ui/index.html");
335        assert!(html.contains("id=\"on-cpu\""));
336        assert!(html.contains("id=\"runtime\""));
337        assert!(html.contains("id=\"uptime\""));
338        assert!(html.contains("id=\"inserts\""));
339        assert!(html.contains("id=\"requeues\""));
340        assert!(html.contains("id=\"completions\""));
341        assert!(html.contains("id=\"local-moves\""));
342        assert!(html.contains("id=\"node-moves\""));
343        assert!(html.contains("id=\"machine-moves\""));
344        assert!(html.contains("id=\"kicks\""));
345        assert!(html.contains("id=\"admits\""));
346        assert!(html.contains("id=\"rejects\""));
347        assert!(html.contains("id=\"misses\""));
348        assert!(html.contains("id=\"gate-rejects\""));
349        assert!(html.contains("id=\"preempt-kicks\""));
350        assert!(html.contains("id=\"preempt-skipped\""));
351        assert!(html.contains("id=\"red-rejects\""));
352        assert!(html.contains("id=\"red-reclaims\""));
353        assert!(!html.contains("id=\"ml-hits\""));
354        assert!(!html.contains("id=\"ml-accuracy\""));
355        assert!(html.contains("on_cpu"));
356        assert!(html.contains("total_runtime"));
357        assert!(html.contains("uptime_ns"));
358        assert!(html.contains("local_moves"));
359        assert!(html.contains("node_moves"));
360        assert!(html.contains("machine_moves"));
361        assert!(html.contains("gate_rejects"));
362        assert!(html.contains("preempt_kicks"));
363        assert!(html.contains("preempt_skipped"));
364        assert!(html.contains("red_rejects"));
365        assert!(html.contains("red_reclaims"));
366        assert!(!html.contains("ml_hits"));
367        assert!(!html.contains("ml_accuracy"));
368        assert!(!html.contains("over_moves"));
369        assert!(!html.contains("over-moves"));
370        assert!(!html.contains("\"parks\""));
371        assert!(!html.contains("id=\"parks\""));
372        assert!(!html.contains("global_moves"));
373        assert!(!html.contains("global-moves"));
374    }
375
376    /* Dashboard hides stale wire fields plus heavy sections. */
377    #[test]
378    fn dashboard_hides_stale_fields() {
379        let html = include_str!("../../ui/index.html");
380        assert!(!html.contains("steal_moves"));
381        assert!(!html.contains("slot_moves"));
382        assert!(!html.contains("throttled_ns"));
383        assert!(!html.contains("nr_throttled"));
384        assert!(!html.contains("bw_moves"));
385        assert!(!html.contains("park_moves"));
386        assert!(!html.contains("over_moves"));
387        assert!(!html.contains("over-moves"));
388        assert!(!html.contains("\"parks\""));
389        assert!(!html.contains("id=\"parks\""));
390        assert!(!html.contains("enq_no_tctx"));
391        assert!(!html.contains("freq_khz"));
392        assert!(!html.contains("cur_freq"));
393        assert!(!html.contains("llc_id"));
394        assert!(!html.contains("core-freq"));
395        assert!(!html.contains("core-llc"));
396        assert!(!html.contains("governor"));
397        assert!(!html.contains("energy"));
398        assert!(!html.contains("id=\"energy-since\""));
399        assert!(!html.contains("id=\"mode-badge\""));
400        assert!(!html.contains("no-tctx"));
401        assert!(!html.contains("id=\"steal\""));
402        assert!(!html.contains("id=\"throttled-ns\""));
403        assert!(!html.contains("ml_hits"));
404        assert!(!html.contains("ml_accuracy"));
405        assert!(!html.contains("ml-total"));
406    }
407
408    /* Dashboard marks the second thread cards with SMT. */
409    #[test]
410    fn dashboard_shows_smt_badge() {
411        let html = include_str!("../../ui/index.html");
412        assert!(html.contains("core-smt"));
413        assert!(html.contains("smtLabel"));
414        assert!(html.contains("cpu.smt"));
415        assert!(html.contains(">SMT<"));
416        assert!(html.contains("var(--warning)"));
417    }
418
419    /* Dashboard clamps on CPU plus shows the live pid count. */
420    #[test]
421    fn dashboard_shows_live_pids_cell() {
422        let html = include_str!("../../ui/index.html");
423        assert!(html.contains("id=\"on-cpu\""));
424        assert!(html.contains("id=\"running-pids\""));
425        assert!(html.contains("livePids"));
426        assert!(html.contains("shownOnCpu"));
427        assert!(html.contains("running_pid"));
428    }
429
430    /* Header capsules plus the per CPU pill center text by height plus width. */
431    #[test]
432    fn dashboard_centers_header_capsules() {
433        let html = include_str!("../../ui/index.html");
434        assert!(html.contains("#status-badge"));
435        assert!(html.contains("#download"));
436        assert!(html.contains(".run-badge"));
437        assert!(html.contains("display: inline-flex"));
438        assert!(html.contains("align-items: center"));
439        assert!(html.contains("justify-content: center"));
440        assert!(html.contains("line-height: 1.4"));
441        assert!(!html.contains("#mode-badge"));
442    }
443
444    /* Dashboard polls once per second with no stored history. */
445    #[test]
446    fn dashboard_polls_once_per_second() {
447        let html = include_str!("../../ui/index.html");
448        assert_eq!(html.matches("setInterval").count(), 1);
449        assert!(!html.contains("localStorage"));
450        assert!(html.contains("/api/stats"));
451        assert!(html.contains("slice_ns"));
452    }
453
454    /* Dashboard shows fixed RED bounds with no ML. */
455    #[test]
456    fn dashboard_shows_fixed_red() {
457        let html = include_str!("../../ui/index.html");
458        assert!(!html.contains("id=\"ml-accuracy\""));
459        assert!(!html.contains("ml_accuracy"));
460        assert!(html.contains("id=\"red-rejects\""));
461        assert!(html.contains("id=\"red-reclaims\""));
462        assert!(html.contains("RED rejects"));
463    }
464
465    /* Summary names RED plus tiers in the running line. */
466    #[test]
467    fn dashboard_summary_names_red() {
468        let html = include_str!("../../ui/index.html");
469        assert!(!html.contains("ML accuracy"));
470        assert!(html.contains("Flow is running"));
471        assert!(html.contains("RED rejects"));
472    }
473}