1use 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
27const PORT: u16 = 50005;
29const POLL: Duration = Duration::from_millis(200);
31const JSON: &str = "application/json";
33const HTML: &str = "text/html";
35
36struct WebState {
38 metrics: WebMetrics,
39}
40
41fn jv<T: Serialize>(v: &T) -> Value {
43 serde_json::to_value(v).unwrap_or_default()
44}
45
46fn jt(v: &Value) -> String {
48 serde_json::to_string(v).unwrap_or("{}".into())
49}
50
51fn 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
65pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}