1use crate::cli::DashboardArgs;
16use crate::core::ProjectConfig;
17
18use anyhow::{Context, Result};
19use axum::{
20 extract::{
21 ws::{Message, WebSocket, WebSocketUpgrade},
22 State,
23 },
24 response::{Html, IntoResponse, Response},
25 routing::get,
26 Router,
27};
28use colored::*;
29use std::path::PathBuf;
30use std::time::Duration;
31use tokio::sync::broadcast;
32
33#[derive(Clone)]
37struct AppState {
38 config: ProjectConfig,
39 cache_file: PathBuf,
40 tx: broadcast::Sender<String>,
41}
42
43pub fn run(args: &DashboardArgs) -> Result<()> {
45 let mut config =
49 ProjectConfig::from_file(args.shared.project.join("forge-guard.toml")).unwrap_or_default();
50 config.project_root = args.shared.project.clone();
51
52 let cache_file = config
53 .project_root
54 .join(&config.cache.directory)
55 .join("last_audit.json");
56
57 let (tx, _) = broadcast::channel(64);
58 let state = AppState {
59 config: config.clone(),
60 cache_file: cache_file.clone(),
61 tx,
62 };
63
64 let host = args.host.clone();
66 let port = args.port;
67 let open = args.open;
68 let watch = args.watch;
69 let dirs = args.dirs.clone();
70 let debounce_ms = args.debounce_ms;
71 let shared = args.shared.clone();
72 let full = args.full;
73
74 let rt = tokio::runtime::Builder::new_multi_thread()
75 .enable_all()
76 .build()
77 .context("Failed to start tokio runtime")?;
78
79 rt.block_on(async move {
80 tokio::spawn(watch_cache_file(state.clone()));
82
83 if watch {
86 tokio::spawn(watch_and_reaudit(
87 state.clone(),
88 shared,
89 dirs.clone(),
90 debounce_ms,
91 full,
92 ));
93 }
94
95 let app = Router::new()
96 .route("/", get(index))
97 .route("/api/audit", get(api_audit))
98 .route("/api/history", get(api_history))
99 .route("/ws", get(ws_handler))
100 .with_state(state);
101
102 let addr = format!("{host}:{port}");
103 let listener = tokio::net::TcpListener::bind(&addr)
104 .await
105 .with_context(|| format!("Failed to bind dashboard server on {addr}"))?;
106
107 eprintln!("{}", "π Forge Guard β Dashboard Mode".bold());
108 eprintln!(
109 " Serving: {}",
110 format!("http://{addr}").cyan().underline()
111 );
112 eprintln!(" Result: {}", cache_file.display().to_string().dimmed());
113 if watch {
114 eprintln!(" Watching: {dirs} (re-audit on change)");
115 }
116 eprintln!(" {}", "Press Ctrl+C to stop".dimmed());
117
118 if open {
119 open_browser(&addr);
120 }
121
122 axum::serve(listener, app)
123 .await
124 .context("Dashboard server error")
125 })
126}
127
128async fn watch_cache_file(state: AppState) {
131 let mut last_modified = file_mtime(&state.cache_file);
132 loop {
133 tokio::time::sleep(Duration::from_millis(1000)).await;
134 let now = file_mtime(&state.cache_file);
135 if now != last_modified {
136 last_modified = now;
137 if let Ok(content) = std::fs::read_to_string(&state.cache_file) {
138 if serde_json::from_str::<crate::core::AuditResult>(&content).is_ok() {
140 let _ = state.tx.send(content);
141 }
142 }
143 }
144 }
145}
146
147async fn watch_and_reaudit(
150 _state: AppState,
151 shared: crate::cli::SharedFlags,
152 dirs: String,
153 debounce_ms: u64,
154 full: bool,
155) {
156 let watch_dirs: Vec<PathBuf> = dirs
157 .split(',')
158 .map(|s| {
159 let p = PathBuf::from(s.trim());
160 if p.is_relative() {
161 shared.project.join(p)
162 } else {
163 p
164 }
165 })
166 .collect();
167
168 if watch_dirs.iter().any(|d| !d.exists()) {
169 eprintln!(
170 "{}",
171 "β οΈ Watch directory missing β re-audit disabled".yellow()
172 );
173 return;
174 }
175
176 let mut last_mod = last_modification(&watch_dirs);
177 let mut last_trigger = std::time::Instant::now();
178 let debounce = Duration::from_millis(debounce_ms);
179
180 loop {
181 tokio::time::sleep(Duration::from_millis(200)).await;
182 let current_mod = last_modification(&watch_dirs);
183 if current_mod > last_mod && last_trigger.elapsed() >= debounce {
184 eprintln!("\n{}", "π Change detected, re-auditing...".bold());
185 let audit_args = crate::cli::AuditArgs {
186 shared: shared.clone(),
187 full,
188 quick: false,
189 summary: false,
190 exploit: full,
191 gas: full,
192 all_chains: false,
193 max_parallel_chains: 4,
194 sources: dirs.clone(),
195 exclude: None,
196 ai: false,
197 ai_provider: "openai".into(),
198 ai_model: "gpt-4".into(),
199 ai_api_key: None,
200 ollama_endpoint: None,
201 ai_full: false,
202 template: None,
203 list_templates: false,
204 suppressions: None,
205 show_suppressed: false,
206 generate_suppressions: false,
207 notify: false,
208 enable_history: false,
209 };
210 if let Err(e) = crate::cli::audit::run(&audit_args) {
211 eprintln!("{} Audit error: {}", "β οΈ".yellow(), e);
212 }
213 last_mod = current_mod;
214 last_trigger = std::time::Instant::now();
215 }
216 }
217}
218
219fn file_mtime(path: &std::path::Path) -> std::time::SystemTime {
221 std::fs::metadata(path)
222 .and_then(|m| m.modified())
223 .unwrap_or(std::time::UNIX_EPOCH)
224}
225
226fn last_modification(dirs: &[PathBuf]) -> std::time::SystemTime {
228 let mut latest = std::time::UNIX_EPOCH;
229 for dir in dirs {
230 for entry in walkdir::WalkDir::new(dir)
231 .into_iter()
232 .filter_map(|e| e.ok())
233 {
234 if entry.file_type().is_file() {
235 if let Ok(metadata) = entry.metadata() {
236 if let Ok(modified) = metadata.modified() {
237 if modified > latest {
238 latest = modified;
239 }
240 }
241 }
242 }
243 }
244 }
245 latest
246}
247
248fn open_browser(addr: &str) {
250 let url = format!("http://{addr}");
251 #[cfg(target_os = "macos")]
252 let _ = std::process::Command::new("open").arg(&url).spawn();
253 #[cfg(target_os = "windows")]
254 let _ = std::process::Command::new("cmd")
255 .args(["/C", "start", "", &url])
256 .spawn();
257 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
258 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
259}
260
261async fn index() -> Html<&'static str> {
267 Html(DASHBOARD_HTML)
268}
269
270async fn api_audit(State(state): State<AppState>) -> Response {
272 match std::fs::read_to_string(&state.cache_file) {
273 Ok(content) => {
274 let status = axum::http::StatusCode::OK;
275 (
276 status,
277 [(axum::http::header::CONTENT_TYPE, "application/json")],
278 content,
279 )
280 .into_response()
281 }
282 Err(_) => {
283 let msg = "{\"error\":\"No audit result yet. Run `forge-guard audit` first.\"}";
284 (
285 axum::http::StatusCode::NOT_FOUND,
286 [(axum::http::header::CONTENT_TYPE, "application/json")],
287 msg,
288 )
289 .into_response()
290 }
291 }
292}
293
294async fn api_history(State(state): State<AppState>) -> Response {
297 let store = match crate::history::HistoryStore::open(&state.config) {
298 Ok(store) => store,
299 Err(e) => {
300 return (
301 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
302 [(axum::http::header::CONTENT_TYPE, "application/json")],
303 format!("{{\"error\":\"Failed to open history database: {}\"}}", e),
304 )
305 .into_response();
306 }
307 };
308
309 let project = state.config.project_root.to_string_lossy().to_string();
313 let mut entries = store.trends(&project, 50).unwrap_or_default();
314 if entries.is_empty() && project != "." {
315 entries = store.trends(".", 50).unwrap_or_default();
316 }
317
318 entries.reverse();
320
321 let points: Vec<serde_json::Value> = entries
322 .iter()
323 .map(|e| {
324 serde_json::json!({
325 "timestamp": e.timestamp,
326 "overall_score": e.overall_score,
327 "risk_level": e.risk_level,
328 "total_findings": e.total_findings,
329 "critical_count": e.critical_count,
330 "high_count": e.high_count,
331 })
332 })
333 .collect();
334 let body = serde_json::to_string(&points).unwrap_or_else(|_| "[]".into());
335 (
336 axum::http::StatusCode::OK,
337 [(axum::http::header::CONTENT_TYPE, "application/json")],
338 body,
339 )
340 .into_response()
341}
342
343async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
346 ws.on_upgrade(move |socket| handle_socket(socket, state))
347}
348
349async fn handle_socket(mut socket: WebSocket, state: AppState) {
350 if let Ok(content) = std::fs::read_to_string(&state.cache_file) {
352 let _ = socket.send(Message::Text(content.into())).await;
353 }
354
355 let mut rx = state.tx.subscribe();
356 loop {
357 tokio::select! {
358 msg = rx.recv() => {
360 match msg {
361 Ok(json) => {
362 if socket.send(Message::Text(json.into())).await.is_err() {
363 return;
364 }
365 }
366 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
367 Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
368 }
369 }
370 msg = socket.recv() => {
372 match msg {
373 Some(Ok(Message::Ping(payload))) => {
374 if socket.send(Message::Pong(payload)).await.is_err() {
375 return;
376 }
377 }
378 Some(Ok(Message::Close(_))) | None => return,
379 Some(Err(_)) => return,
380 _ => {}
381 }
382 }
383 }
384 }
385}
386
387const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
392<html lang="en">
393<head>
394<meta charset="UTF-8">
395<meta name="viewport" content="width=device-width, initial-scale=1.0">
396<title>Forge Guard β Dashboard</title>
397<style>
398 :root {
399 --bg: #0d1117; --panel: #161b22; --panel-2: #1c2129; --border: #2d333b;
400 --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
401 --crit: #f85149; --high: #ff7b72; --med: #d29922; --low: #58a6ff; --info: #8b949e;
402 --good: #3fb950;
403 }
404 * { box-sizing: border-box; margin: 0; padding: 0; }
405 body { background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
406 .wrap { max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; }
407 header { display: flex; align-items: center; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border); margin-bottom: 22px; flex-wrap: wrap; }
408 .logo { font-size: 20px; font-weight: 700; }
409 .logo span { color: var(--muted); font-weight: 400; }
410 .meta { margin-left: auto; text-align: right; color: var(--muted); font-size: 12px; }
411 .meta b { color: var(--text); }
412 #conn { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); margin-right: 6px; }
413 #conn.on { background: var(--good); }
414 .grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; }
415 @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
416 .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 20px; }
417 .panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
418 /* Score gauge */
419 .gauge { position: relative; width: 200px; height: 120px; margin: 0 auto 10px; }
420 .gauge svg { width: 100%; height: 100%; }
421 .gauge .arc-bg { fill: none; stroke: var(--panel-2); stroke-width: 14; }
422 .gauge .arc { fill: none; stroke-width: 14; stroke-linecap: round; transition: stroke-dashoffset .6s ease, stroke .6s; }
423 .gauge .val { position: absolute; inset: 0; top: 60px; text-align: center; font-size: 34px; font-weight: 700; }
424 .gauge .lbl { position: absolute; inset: 0; top: 96px; text-align: center; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
425 .stat { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
426 .stat:last-child { border-bottom: none; }
427 .stat .k { color: var(--muted); }
428 .stat .v { font-weight: 600; }
429 .pass { color: var(--good); } .fail { color: var(--crit); }
430 /* Severity bars */
431 .sev-row { display: grid; grid-template-columns: 90px 1fr 34px; align-items: center; gap: 10px; margin-bottom: 9px; }
432 .sev-row .lbl { font-size: 12px; color: var(--muted); text-transform: capitalize; }
433 .sev-track { height: 10px; background: var(--panel-2); border-radius: 5px; overflow: hidden; }
434 .sev-fill { height: 100%; border-radius: 5px; transition: width .5s ease; }
435 .sev-row .n { font-weight: 700; text-align: right; font-size: 13px; }
436 /* Category scores */
437 .cat { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; font-size: 12.5px; border-bottom: 1px dashed var(--border); }
438 .cat:last-child { border-bottom: none; }
439 .cat .name { color: var(--muted); text-transform: capitalize; }
440 .cat .bar { flex: 1; height: 6px; background: var(--panel-2); border-radius: 3px; margin: 0 12px; overflow: hidden; }
441 .cat .bar i { display: block; height: 100%; border-radius: 3px; transition: width .5s ease; }
442 .cat .score { font-weight: 700; width: 34px; text-align: right; }
443 /* Filters */
444 .filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
445 .chip { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); cursor: pointer; font-size: 12.5px; user-select: none; }
446 .chip.active { color: #fff; border-color: transparent; }
447 .chip.crit.active { background: var(--crit); } .chip.high.active { background: var(--high); }
448 .chip.med.active { background: var(--med); } .chip.low.active { background: var(--low); }
449 .chip.info.active { background: var(--info); } .chip.all.active { background: var(--accent); }
450 .chip .cnt { opacity: .8; font-size: 11px; margin-left: 4px; }
451 input[type=search] { flex: 1; min-width: 180px; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; }
452 input[type=search]:focus { outline: 1px solid var(--accent); }
453 select { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; }
454 /* Findings */
455 .finding { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 10px; background: var(--panel); overflow: hidden; }
456 .finding .head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; }
457 .finding .head:hover { background: var(--panel-2); }
458 .sev { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; padding: 2px 8px; border-radius: 4px; color: #fff; flex-shrink: 0; }
459 .finding .title { font-weight: 600; font-size: 13.5px; }
460 .finding .loc { color: var(--muted); font-size: 12px; margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
461 .finding .body { display: none; padding: 12px 14px; border-top: 1px solid var(--border); font-size: 13px; }
462 .finding.open .body { display: block; }
463 .finding .body h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 12px 0 4px; }
464 .finding .body pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 10px; overflow-x: auto; font-size: 12px; margin-top: 4px; }
465 .finding .body .chain-tag { display: inline-block; background: var(--panel-2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 8px; font-size: 11px; color: var(--accent); margin-bottom: 6px; }
466 .arrow { color: var(--muted); transition: transform .15s; font-size: 11px; }
467 .finding.open .arrow { transform: rotate(90deg); }
468 .empty { color: var(--muted); text-align: center; padding: 30px 0; }
469 .empty .big { font-size: 34px; display: block; margin-bottom: 10px; }
470 a { color: var(--accent); }
471</style>
472</head>
473<body>
474<div class="wrap">
475 <header>
476 <div class="logo">π Forge Guard <span>Dashboard</span></div>
477 <div class="meta">
478 <div><span id="conn"></span><span id="connText">connectingβ¦</span></div>
479 <div>updated <b id="updated">β</b></div>
480 </div>
481 </header>
482
483 <div class="grid">
484 <div>
485 <div class="panel">
486 <h2>Overall Score</h2>
487 <div class="gauge">
488 <svg viewBox="0 0 200 120">
489 <path class="arc-bg" d="M 20 105 A 80 80 0 0 1 180 105"/>
490 <path class="arc" id="arc" d="M 20 105 A 80 80 0 0 1 180 105" stroke="#58a6ff"/>
491 </svg>
492 <div class="val" id="score">β</div>
493 <div class="lbl" id="risk">β</div>
494 </div>
495 <div id="stats"></div>
496 </div>
497 <div class="panel">
498 <h2>Severity Distribution</h2>
499 <div id="sevBars"></div>
500 </div>
501 </div>
502
503 <div>
504 <div class="panel">
505 <h2>Category Scores</h2>
506 <div id="cats"></div>
507 </div>
508 <div class="panel">
509 <h2>Score Trend <span style="text-transform:none;color:var(--muted);font-weight:400">(history DB)</span></h2>
510 <div id="trend"><div class="empty"><span class="big">π</span>Loading historyβ¦</div></div>
511 </div>
512 <div class="panel">
513 <h2>Findings</h2>
514 <div class="filters">
515 <span class="chip all active" data-sev="all">All <span class="cnt" id="cAll"></span></span>
516 <span class="chip crit" data-sev="critical">Critical <span class="cnt" id="cCrit"></span></span>
517 <span class="chip high" data-sev="high">High <span class="cnt" id="cHigh"></span></span>
518 <span class="chip med" data-sev="medium">Medium <span class="cnt" id="cMed"></span></span>
519 <span class="chip low" data-sev="low">Low <span class="cnt" id="cLow"></span></span>
520 <span class="chip info" data-sev="info">Info <span class="cnt" id="cInfo"></span></span>
521 <input type="search" id="search" placeholder="Search title, file, idβ¦">
522 <select id="chainFilter" style="display:none"></select>
523 </div>
524 <div id="findings"></div>
525 </div>
526 </div>
527 </div>
528</div>
529
530<script>
531const SEV_COLORS = { critical: "#f85149", high: "#ff7b72", medium: "#d29922", low: "#58a6ff", info: "#8b949e" };
532const CAT_KEYS = ["access_control","security","fuzzing","gas","architecture","upgradeability",
533 "dependencies","deployment","proxy_safety","chain_compatibility","production_readiness","exploit_resistance"];
534let result = null;
535let sevFilter = "all";
536let chainFilter = "all";
537
538function esc(s) {
539 return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
540}
541function scoreColor(v) { return v >= 80 ? "#3fb950" : v >= 60 ? "#d29922" : "#f85149"; }
542function severityOf(f) { return (f.severity || "info").toLowerCase(); }
543
544function gauge(score) {
545 const c = scoreColor(score);
546 const circ = 2 * Math.PI * 80;
547 const arc = document.getElementById("arc");
548 arc.style.strokeDasharray = circ;
549 arc.style.strokeDashoffset = circ * (1 - Math.min(score, 100) / 100);
550 arc.style.stroke = c;
551 document.getElementById("score").textContent = score;
552 document.getElementById("score").style.color = c;
553}
554
555function render() {
556 if (!result) return;
557 const findings = result.findings || [];
558 const summary = result.summary || {};
559 const counts = { critical: summary.critical_count || 0, high: summary.high_count || 0,
560 medium: summary.medium_count || 0, low: summary.low_count || 0, info: summary.info_count || 0 };
561 const total = findings.length || Object.values(counts).reduce((a, b) => a + b, 0);
562 document.getElementById("cAll").textContent = total;
563 document.getElementById("cCrit").textContent = counts.critical;
564 document.getElementById("cHigh").textContent = counts.high;
565 document.getElementById("cMed").textContent = counts.medium;
566 document.getElementById("cLow").textContent = counts.low;
567 document.getElementById("cInfo").textContent = counts.info;
568
569 gauge(result.overall_score || 0);
570 document.getElementById("risk").textContent = result.risk_level || "β";
571
572 document.getElementById("stats").innerHTML =
573 stat("Project", result.project_name) +
574 stat("Chain", result.chains && result.chains.length > 1 ? result.chains.length + " chains" : (result.chain || "β")) +
575 stat("Duration", (result.duration_seconds ?? 0).toFixed(2) + "s") +
576 stat("Files", summary.files_analyzed ?? 0) +
577 stat("Production ready", result.production_ready ? '<span class="pass">β
YES</span>' : '<span class="fail">β NO</span>') +
578 stat("Deployment", result.deployment_approved ? '<span class="pass">β
APPROVED</span>' : '<span class="fail">β BLOCKED</span>');
579
580 document.getElementById("sevBars").innerHTML =
581 sevBar("critical", counts.critical, total) + sevBar("high", counts.high, total) +
582 sevBar("medium", counts.medium, total) + sevBar("low", counts.low, total) + sevBar("info", counts.info, total);
583
584 document.getElementById("cats").innerHTML = (result.scores ? CAT_KEYS : [])
585 .filter(k => result.scores[k] !== undefined)
586 .map(k => {
587 const v = result.scores[k];
588 return '<div class="cat"><span class="name">' + esc(k.replace(/_/g, " ")) +
589 '</span><span class="bar"><i style="width:' + v + '%;background:' + scoreColor(v) + '"></i></span>' +
590 '<span class="score" style="color:' + scoreColor(v) + '">' + v + '</span></div>';
591 }).join("") || '<div class="empty"><span class="big">π</span>No score data</div>';
592
593 // Chain filter (multi-chain audits only)
594 const chains = [...new Set(findings.map(f => f.chain).filter(Boolean))];
595 const cf = document.getElementById("chainFilter");
596 if (chains.length > 1) {
597 cf.style.display = "";
598 cf.innerHTML = '<option value="all">All chains</option>' + chains.map(c => '<option value="' + esc(c) + '">' + esc(c) + "</option>").join("");
599 } else {
600 cf.style.display = "none";
601 cf.innerHTML = "";
602 }
603 chainFilter = "all";
604 cf.value = "all";
605
606 renderFindings(findings);
607}
608
609function stat(k, v) { return '<div class="stat"><span class="k">' + esc(k) + '</span><span class="v">' + v + "</span></div>"; }
610
611// ββ Score trend chart (SVG line chart, no external deps) ββ
612function renderTrend(points) {
613 const el = document.getElementById("trend");
614 if (!points || points.length < 2) {
615 el.innerHTML = '<div class="empty"><span class="big">π</span>Not enough history yet. Run audits with <code>--enable-history</code> to track trends.</div>';
616 return;
617 }
618 const W = 660, H = 180, PAD = 34;
619 const min = Math.min(...points.map(p => p.overall_score)) - 5;
620 const max = Math.max(...points.map(p => p.overall_score)) + 5;
621 const lo = Math.max(0, Math.floor(min / 10) * 10);
622 const hi = Math.min(100, Math.ceil(max / 10) * 10);
623 const x = i => PAD + i * (W - PAD * 2) / Math.max(points.length - 1, 1);
624 const y = s => H - PAD - (s - lo) / (hi - lo) * (H - PAD * 2);
625 const line = points.map((p, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(p.overall_score).toFixed(1)).join(" ");
626 const dots = points.map((p, i) =>
627 '<circle cx="' + x(i).toFixed(1) + '" cy="' + y(p.overall_score).toFixed(1) + '" r="4" fill="' + scoreColor(p.overall_score) + '">' +
628 '<title>' + esc(p.timestamp) + " β score " + p.overall_score + " β " + p.total_findings + " findings</title></circle>").join("");
629 // Y-axis gridlines + labels (every 20 points on the score range)
630 let grid = "";
631 for (let v = lo; v <= hi; v += 20) {
632 const gy = y(v);
633 grid += '<line x1="' + PAD + '" y1="' + gy.toFixed(1) + '" x2="' + (W - PAD) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-dasharray="2 4"/>' +
634 '<text x="4" y="' + (gy + 3).toFixed(1) + '" fill="var(--muted)" font-size="10">' + v + "</text>";
635 }
636 // X-axis labels (first, last, and every 4th point)
637 let labels = "";
638 points.forEach((p, i) => {
639 if (i === 0 || i === points.length - 1 || i % 4 === 0) {
640 const ts = new Date(p.timestamp);
641 const label = isNaN(ts) ? "" : (ts.getMonth() + 1) + "/" + ts.getDate();
642 labels += '<text x="' + x(i).toFixed(1) + '" y="' + (H - 10) + '" text-anchor="middle" fill="var(--muted)" font-size="10">' + esc(label) + "</text>";
643 }
644 });
645 el.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto">' +
646 grid +
647 '<path d="' + line + '" fill="none" stroke="var(--accent)" stroke-width="2.5"/>' +
648 dots + labels + "</svg>";
649}
650
651function loadHistory() {
652 fetch("/api/history").then(r => r.ok ? r.json() : []).then(renderTrend).catch(() => {});
653}
654function sevBar(sev, n, total) {
655 const pct = total ? Math.round(n / total * 100) : 0;
656 return '<div class="sev-row"><span class="lbl">' + sev + '</span><span class="sev-track"><span class="sev-fill" style="width:' + pct + '%;background:' + SEV_COLORS[sev] + '"></span></span><span class="n" style="color:' + SEV_COLORS[sev] + '">' + n + "</span></div>";
657}
658
659function renderFindings(findings) {
660 const q = (document.getElementById("search").value || "").toLowerCase();
661 const list = findings.filter(f => {
662 if (sevFilter !== "all" && severityOf(f) !== sevFilter) return false;
663 if (chainFilter !== "all" && (f.chain || "") !== chainFilter) return false;
664 if (q) {
665 const hay = (f.title + " " + (f.file || "") + " " + f.id + " " + (f.description || "")).toLowerCase();
666 if (!hay.includes(q)) return false;
667 }
668 return true;
669 });
670
671 const el = document.getElementById("findings");
672 if (!list.length) {
673 el.innerHTML = '<div class="empty"><span class="big">π</span>No findings match</div>';
674 return;
675 }
676 el.innerHTML = list.map(f => {
677 const sev = severityOf(f);
678 const loc = f.file ? (esc(f.file) + (f.line ? ":" + f.line : "")) : "";
679 const chain = f.chain ? '<span class="chain-tag">βοΈ ' + esc(f.chain) + "</span>" : "";
680 const refs = (f.references || []).length ? "<h4>References</h4><ul>" + f.references.map(r => "<li>" + esc(r) + "</li>").join("") + "</ul>" : "";
681 const exploit = (f.exploit_path && f.exploit_path.length) ? "<h4>Exploit path</h4><ol>" + f.exploit_path.map(s => "<li>" + esc(s) + "</li>").join("") + "</ol>" : "";
682 return '<div class="finding" data-idx="' + findings.indexOf(f) + '">' +
683 '<div class="head"><span class="sev" style="background:' + SEV_COLORS[sev] + '">' + sev + "</span>" +
684 '<span class="title">' + esc(f.title) + "</span>" +
685 '<span class="loc">' + loc + '</span><span class="arrow">βΆ</span></div>' +
686 '<div class="body">' + chain +
687 "<h4>Description</h4><p>" + esc(f.description || "β") + "</p>" +
688 (f.code_snippet ? "<h4>Code</h4><pre>" + esc(f.code_snippet) + "</pre>" : "") +
689 "<h4>Recommendation</h4><p>" + esc(f.recommendation || "β") + "</p>" +
690 (f.blocks_deployment ? '<p style="color:var(--crit);margin-top:8px">β Blocks deployment</p>' : "") +
691 exploit + refs + "</div></div>";
692 }).join("");
693
694 el.querySelectorAll(".finding .head").forEach(h => {
695 h.addEventListener("click", () => h.parentElement.classList.toggle("open"));
696 });
697}
698
699document.querySelectorAll(".chip").forEach(chip => {
700 chip.addEventListener("click", () => {
701 document.querySelectorAll(".chip").forEach(c => c.classList.remove("active"));
702 chip.classList.add("active");
703 sevFilter = chip.dataset.sev;
704 if (result) renderFindings(result.findings || []);
705 });
706});
707document.getElementById("search").addEventListener("input", () => result && renderFindings(result.findings || []));
708document.getElementById("chainFilter").addEventListener("change", e => {
709 chainFilter = e.target.value;
710 if (result) renderFindings(result.findings || []);
711});
712
713function apply(data) {
714 result = data;
715 document.getElementById("updated").textContent = new Date().toLocaleTimeString();
716 render();
717 loadHistory(); // re-fetch trends whenever a new audit arrives
718}
719
720// Initial load + WebSocket live updates.
721loadHistory();
722fetch("/api/audit").then(r => r.ok ? r.json() : null).then(d => { if (d) apply(d); }).catch(() => {});
723const wsProto = location.protocol === "https:" ? "wss" : "ws";
724let ws = null;
725function connect() {
726 ws = new WebSocket(wsProto + "://" + location.host + "/ws");
727 ws.onopen = () => { document.getElementById("conn").classList.add("on"); document.getElementById("connText").textContent = "live"; };
728 ws.onmessage = e => { try { apply(JSON.parse(e.data)); } catch (_) {} };
729 ws.onclose = () => { document.getElementById("conn").classList.remove("on"); document.getElementById("connText").textContent = "reconnectingβ¦"; setTimeout(connect, 2000); };
730 ws.onerror = () => ws.close();
731}
732connect();
733</script>
734</body>
735</html>
736"##;
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
743 fn test_dashboard_html_has_core_elements() {
744 assert!(DASHBOARD_HTML.contains("Forge Guard"));
745 assert!(DASHBOARD_HTML.contains("gauge"));
746 assert!(DASHBOARD_HTML.contains("WebSocket"));
747 assert!(DASHBOARD_HTML.contains("/api/audit"));
748 assert!(DASHBOARD_HTML.contains("/ws"));
749 assert!(DASHBOARD_HTML.contains("severity"));
750 }
751 #[test]
752 fn test_dashboard_html_has_charts_and_filters() {
753 assert!(
754 DASHBOARD_HTML.contains("sevBars"),
755 "severity distribution chart"
756 );
757 assert!(DASHBOARD_HTML.contains("Category Scores"));
758 assert!(
759 DASHBOARD_HTML.contains("chainFilter"),
760 "chain filter for multi-chain"
761 );
762 assert!(
763 DASHBOARD_HTML.contains("exploit_path"),
764 "expandable exploit details"
765 );
766 }
767
768 #[test]
769 fn test_dashboard_html_has_score_trend_chart() {
770 assert!(
771 DASHBOARD_HTML.contains("Score Trend"),
772 "trend panel heading"
773 );
774 assert!(
775 DASHBOARD_HTML.contains("/api/history"),
776 "history API endpoint"
777 );
778 assert!(
779 DASHBOARD_HTML.contains("renderTrend"),
780 "trend rendering function"
781 );
782 assert!(
783 DASHBOARD_HTML.contains("loadHistory"),
784 "history loader wired in"
785 );
786 assert!(
787 DASHBOARD_HTML.contains("--enable-history"),
788 "empty-state hint should mention --enable-history"
789 );
790 }
791
792 #[test]
793 fn test_dashboard_history_api_returns_json_points() {
794 let json = r#"[{"timestamp":"2026-08-17T10:00:00Z","overall_score":85,"risk_level":"LOW","total_findings":3,"critical_count":0,"high_count":1}]"#;
797 let v: serde_json::Value = serde_json::from_str(json).expect("valid history JSON");
798 let point = &v[0];
799 assert_eq!(point["overall_score"], 85);
800 assert_eq!(point["total_findings"], 3);
801 assert_eq!(point["high_count"], 1);
802 assert!(point["timestamp"].is_string());
803 assert!(point["risk_level"].is_string());
804 }
805
806 #[test]
807 fn test_dashboard_html_escapes_user_content() {
808 assert!(DASHBOARD_HTML.contains("replace(/[&<>\"']/g"));
810 }
811}