use crate::cli::DashboardArgs;
use crate::core::ProjectConfig;
use anyhow::{Context, Result};
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use colored::*;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::broadcast;
#[derive(Clone)]
struct AppState {
config: ProjectConfig,
cache_file: PathBuf,
tx: broadcast::Sender<String>,
}
pub fn run(args: &DashboardArgs) -> Result<()> {
let mut config =
ProjectConfig::from_file(args.shared.project.join("forge-guard.toml")).unwrap_or_default();
config.project_root = args.shared.project.clone();
let cache_file = config
.project_root
.join(&config.cache.directory)
.join("last_audit.json");
let (tx, _) = broadcast::channel(64);
let state = AppState {
config: config.clone(),
cache_file: cache_file.clone(),
tx,
};
let host = args.host.clone();
let port = args.port;
let open = args.open;
let watch = args.watch;
let dirs = args.dirs.clone();
let debounce_ms = args.debounce_ms;
let shared = args.shared.clone();
let full = args.full;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Failed to start tokio runtime")?;
rt.block_on(async move {
tokio::spawn(watch_cache_file(state.clone()));
if watch {
tokio::spawn(watch_and_reaudit(
state.clone(),
shared,
dirs.clone(),
debounce_ms,
full,
));
}
let app = Router::new()
.route("/", get(index))
.route("/api/audit", get(api_audit))
.route("/api/history", get(api_history))
.route("/ws", get(ws_handler))
.with_state(state);
let addr = format!("{host}:{port}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.with_context(|| format!("Failed to bind dashboard server on {addr}"))?;
eprintln!("{}", "📊 Forge Guard — Dashboard Mode".bold());
eprintln!(
" Serving: {}",
format!("http://{addr}").cyan().underline()
);
eprintln!(" Result: {}", cache_file.display().to_string().dimmed());
if watch {
eprintln!(" Watching: {dirs} (re-audit on change)");
}
eprintln!(" {}", "Press Ctrl+C to stop".dimmed());
if open {
open_browser(&addr);
}
axum::serve(listener, app)
.await
.context("Dashboard server error")
})
}
async fn watch_cache_file(state: AppState) {
let mut last_modified = file_mtime(&state.cache_file);
loop {
tokio::time::sleep(Duration::from_millis(1000)).await;
let now = file_mtime(&state.cache_file);
if now != last_modified {
last_modified = now;
if let Ok(content) = std::fs::read_to_string(&state.cache_file) {
if serde_json::from_str::<crate::core::AuditResult>(&content).is_ok() {
let _ = state.tx.send(content);
}
}
}
}
}
async fn watch_and_reaudit(
_state: AppState,
shared: crate::cli::SharedFlags,
dirs: String,
debounce_ms: u64,
full: bool,
) {
let watch_dirs: Vec<PathBuf> = dirs
.split(',')
.map(|s| {
let p = PathBuf::from(s.trim());
if p.is_relative() {
shared.project.join(p)
} else {
p
}
})
.collect();
if watch_dirs.iter().any(|d| !d.exists()) {
eprintln!(
"{}",
"⚠️ Watch directory missing — re-audit disabled".yellow()
);
return;
}
let mut last_mod = last_modification(&watch_dirs);
let mut last_trigger = std::time::Instant::now();
let debounce = Duration::from_millis(debounce_ms);
loop {
tokio::time::sleep(Duration::from_millis(200)).await;
let current_mod = last_modification(&watch_dirs);
if current_mod > last_mod && last_trigger.elapsed() >= debounce {
eprintln!("\n{}", "🔄 Change detected, re-auditing...".bold());
let audit_args = crate::cli::AuditArgs {
shared: shared.clone(),
full,
quick: false,
summary: false,
exploit: full,
gas: full,
all_chains: false,
max_parallel_chains: 4,
sources: dirs.clone(),
exclude: None,
ai: false,
ai_provider: "openai".into(),
ai_model: "gpt-4".into(),
ai_api_key: None,
ollama_endpoint: None,
ai_full: false,
template: None,
list_templates: false,
suppressions: None,
show_suppressed: false,
generate_suppressions: false,
notify: false,
enable_history: false,
};
if let Err(e) = crate::cli::audit::run(&audit_args) {
eprintln!("{} Audit error: {}", "⚠️".yellow(), e);
}
last_mod = current_mod;
last_trigger = std::time::Instant::now();
}
}
}
fn file_mtime(path: &std::path::Path) -> std::time::SystemTime {
std::fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH)
}
fn last_modification(dirs: &[PathBuf]) -> std::time::SystemTime {
let mut latest = std::time::UNIX_EPOCH;
for dir in dirs {
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
if modified > latest {
latest = modified;
}
}
}
}
}
}
latest
}
fn open_browser(addr: &str) {
let url = format!("http://{addr}");
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&url).spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", &url])
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
}
async fn index() -> Html<&'static str> {
Html(DASHBOARD_HTML)
}
async fn api_audit(State(state): State<AppState>) -> Response {
match std::fs::read_to_string(&state.cache_file) {
Ok(content) => {
let status = axum::http::StatusCode::OK;
(
status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
content,
)
.into_response()
}
Err(_) => {
let msg = "{\"error\":\"No audit result yet. Run `forge-guard audit` first.\"}";
(
axum::http::StatusCode::NOT_FOUND,
[(axum::http::header::CONTENT_TYPE, "application/json")],
msg,
)
.into_response()
}
}
}
async fn api_history(State(state): State<AppState>) -> Response {
let store = match crate::history::HistoryStore::open(&state.config) {
Ok(store) => store,
Err(e) => {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
[(axum::http::header::CONTENT_TYPE, "application/json")],
format!("{{\"error\":\"Failed to open history database: {}\"}}", e),
)
.into_response();
}
};
let project = state.config.project_root.to_string_lossy().to_string();
let mut entries = store.trends(&project, 50).unwrap_or_default();
if entries.is_empty() && project != "." {
entries = store.trends(".", 50).unwrap_or_default();
}
entries.reverse();
let points: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"timestamp": e.timestamp,
"overall_score": e.overall_score,
"risk_level": e.risk_level,
"total_findings": e.total_findings,
"critical_count": e.critical_count,
"high_count": e.high_count,
})
})
.collect();
let body = serde_json::to_string(&points).unwrap_or_else(|_| "[]".into());
(
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: AppState) {
if let Ok(content) = std::fs::read_to_string(&state.cache_file) {
let _ = socket.send(Message::Text(content.into())).await;
}
let mut rx = state.tx.subscribe();
loop {
tokio::select! {
msg = rx.recv() => {
match msg {
Ok(json) => {
if socket.send(Message::Text(json.into())).await.is_err() {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Ping(payload))) => {
if socket.send(Message::Pong(payload)).await.is_err() {
return;
}
}
Some(Ok(Message::Close(_))) | None => return,
Some(Err(_)) => return,
_ => {}
}
}
}
}
}
const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forge Guard — Dashboard</title>
<style>
:root {
--bg: #0d1117; --panel: #161b22; --panel-2: #1c2129; --border: #2d333b;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--crit: #f85149; --high: #ff7b72; --med: #d29922; --low: #58a6ff; --info: #8b949e;
--good: #3fb950;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; }
header { display: flex; align-items: center; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border); margin-bottom: 22px; flex-wrap: wrap; }
.logo { font-size: 20px; font-weight: 700; }
.logo span { color: var(--muted); font-weight: 400; }
.meta { margin-left: auto; text-align: right; color: var(--muted); font-size: 12px; }
.meta b { color: var(--text); }
#conn { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); margin-right: 6px; }
#conn.on { background: var(--good); }
.grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 20px; }
.panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
/* Score gauge */
.gauge { position: relative; width: 200px; height: 120px; margin: 0 auto 10px; }
.gauge svg { width: 100%; height: 100%; }
.gauge .arc-bg { fill: none; stroke: var(--panel-2); stroke-width: 14; }
.gauge .arc { fill: none; stroke-width: 14; stroke-linecap: round; transition: stroke-dashoffset .6s ease, stroke .6s; }
.gauge .val { position: absolute; inset: 0; top: 60px; text-align: center; font-size: 34px; font-weight: 700; }
.gauge .lbl { position: absolute; inset: 0; top: 96px; text-align: center; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
.stat { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
.stat:last-child { border-bottom: none; }
.stat .k { color: var(--muted); }
.stat .v { font-weight: 600; }
.pass { color: var(--good); } .fail { color: var(--crit); }
/* Severity bars */
.sev-row { display: grid; grid-template-columns: 90px 1fr 34px; align-items: center; gap: 10px; margin-bottom: 9px; }
.sev-row .lbl { font-size: 12px; color: var(--muted); text-transform: capitalize; }
.sev-track { height: 10px; background: var(--panel-2); border-radius: 5px; overflow: hidden; }
.sev-fill { height: 100%; border-radius: 5px; transition: width .5s ease; }
.sev-row .n { font-weight: 700; text-align: right; font-size: 13px; }
/* Category scores */
.cat { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; font-size: 12.5px; border-bottom: 1px dashed var(--border); }
.cat:last-child { border-bottom: none; }
.cat .name { color: var(--muted); text-transform: capitalize; }
.cat .bar { flex: 1; height: 6px; background: var(--panel-2); border-radius: 3px; margin: 0 12px; overflow: hidden; }
.cat .bar i { display: block; height: 100%; border-radius: 3px; transition: width .5s ease; }
.cat .score { font-weight: 700; width: 34px; text-align: right; }
/* Filters */
.filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
.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; }
.chip.active { color: #fff; border-color: transparent; }
.chip.crit.active { background: var(--crit); } .chip.high.active { background: var(--high); }
.chip.med.active { background: var(--med); } .chip.low.active { background: var(--low); }
.chip.info.active { background: var(--info); } .chip.all.active { background: var(--accent); }
.chip .cnt { opacity: .8; font-size: 11px; margin-left: 4px; }
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; }
input[type=search]:focus { outline: 1px solid var(--accent); }
select { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; }
/* Findings */
.finding { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 10px; background: var(--panel); overflow: hidden; }
.finding .head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; }
.finding .head:hover { background: var(--panel-2); }
.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; }
.finding .title { font-weight: 600; font-size: 13.5px; }
.finding .loc { color: var(--muted); font-size: 12px; margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
.finding .body { display: none; padding: 12px 14px; border-top: 1px solid var(--border); font-size: 13px; }
.finding.open .body { display: block; }
.finding .body h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 12px 0 4px; }
.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; }
.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; }
.arrow { color: var(--muted); transition: transform .15s; font-size: 11px; }
.finding.open .arrow { transform: rotate(90deg); }
.empty { color: var(--muted); text-align: center; padding: 30px 0; }
.empty .big { font-size: 34px; display: block; margin-bottom: 10px; }
a { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">🔒 Forge Guard <span>Dashboard</span></div>
<div class="meta">
<div><span id="conn"></span><span id="connText">connecting…</span></div>
<div>updated <b id="updated">—</b></div>
</div>
</header>
<div class="grid">
<div>
<div class="panel">
<h2>Overall Score</h2>
<div class="gauge">
<svg viewBox="0 0 200 120">
<path class="arc-bg" d="M 20 105 A 80 80 0 0 1 180 105"/>
<path class="arc" id="arc" d="M 20 105 A 80 80 0 0 1 180 105" stroke="#58a6ff"/>
</svg>
<div class="val" id="score">—</div>
<div class="lbl" id="risk">—</div>
</div>
<div id="stats"></div>
</div>
<div class="panel">
<h2>Severity Distribution</h2>
<div id="sevBars"></div>
</div>
</div>
<div>
<div class="panel">
<h2>Category Scores</h2>
<div id="cats"></div>
</div>
<div class="panel">
<h2>Score Trend <span style="text-transform:none;color:var(--muted);font-weight:400">(history DB)</span></h2>
<div id="trend"><div class="empty"><span class="big">📈</span>Loading history…</div></div>
</div>
<div class="panel">
<h2>Findings</h2>
<div class="filters">
<span class="chip all active" data-sev="all">All <span class="cnt" id="cAll"></span></span>
<span class="chip crit" data-sev="critical">Critical <span class="cnt" id="cCrit"></span></span>
<span class="chip high" data-sev="high">High <span class="cnt" id="cHigh"></span></span>
<span class="chip med" data-sev="medium">Medium <span class="cnt" id="cMed"></span></span>
<span class="chip low" data-sev="low">Low <span class="cnt" id="cLow"></span></span>
<span class="chip info" data-sev="info">Info <span class="cnt" id="cInfo"></span></span>
<input type="search" id="search" placeholder="Search title, file, id…">
<select id="chainFilter" style="display:none"></select>
</div>
<div id="findings"></div>
</div>
</div>
</div>
</div>
<script>
const SEV_COLORS = { critical: "#f85149", high: "#ff7b72", medium: "#d29922", low: "#58a6ff", info: "#8b949e" };
const CAT_KEYS = ["access_control","security","fuzzing","gas","architecture","upgradeability",
"dependencies","deployment","proxy_safety","chain_compatibility","production_readiness","exploit_resistance"];
let result = null;
let sevFilter = "all";
let chainFilter = "all";
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
}
function scoreColor(v) { return v >= 80 ? "#3fb950" : v >= 60 ? "#d29922" : "#f85149"; }
function severityOf(f) { return (f.severity || "info").toLowerCase(); }
function gauge(score) {
const c = scoreColor(score);
const circ = 2 * Math.PI * 80;
const arc = document.getElementById("arc");
arc.style.strokeDasharray = circ;
arc.style.strokeDashoffset = circ * (1 - Math.min(score, 100) / 100);
arc.style.stroke = c;
document.getElementById("score").textContent = score;
document.getElementById("score").style.color = c;
}
function render() {
if (!result) return;
const findings = result.findings || [];
const summary = result.summary || {};
const counts = { critical: summary.critical_count || 0, high: summary.high_count || 0,
medium: summary.medium_count || 0, low: summary.low_count || 0, info: summary.info_count || 0 };
const total = findings.length || Object.values(counts).reduce((a, b) => a + b, 0);
document.getElementById("cAll").textContent = total;
document.getElementById("cCrit").textContent = counts.critical;
document.getElementById("cHigh").textContent = counts.high;
document.getElementById("cMed").textContent = counts.medium;
document.getElementById("cLow").textContent = counts.low;
document.getElementById("cInfo").textContent = counts.info;
gauge(result.overall_score || 0);
document.getElementById("risk").textContent = result.risk_level || "—";
document.getElementById("stats").innerHTML =
stat("Project", result.project_name) +
stat("Chain", result.chains && result.chains.length > 1 ? result.chains.length + " chains" : (result.chain || "—")) +
stat("Duration", (result.duration_seconds ?? 0).toFixed(2) + "s") +
stat("Files", summary.files_analyzed ?? 0) +
stat("Production ready", result.production_ready ? '<span class="pass">✅ YES</span>' : '<span class="fail">❌ NO</span>') +
stat("Deployment", result.deployment_approved ? '<span class="pass">✅ APPROVED</span>' : '<span class="fail">❌ BLOCKED</span>');
document.getElementById("sevBars").innerHTML =
sevBar("critical", counts.critical, total) + sevBar("high", counts.high, total) +
sevBar("medium", counts.medium, total) + sevBar("low", counts.low, total) + sevBar("info", counts.info, total);
document.getElementById("cats").innerHTML = (result.scores ? CAT_KEYS : [])
.filter(k => result.scores[k] !== undefined)
.map(k => {
const v = result.scores[k];
return '<div class="cat"><span class="name">' + esc(k.replace(/_/g, " ")) +
'</span><span class="bar"><i style="width:' + v + '%;background:' + scoreColor(v) + '"></i></span>' +
'<span class="score" style="color:' + scoreColor(v) + '">' + v + '</span></div>';
}).join("") || '<div class="empty"><span class="big">📭</span>No score data</div>';
// Chain filter (multi-chain audits only)
const chains = [...new Set(findings.map(f => f.chain).filter(Boolean))];
const cf = document.getElementById("chainFilter");
if (chains.length > 1) {
cf.style.display = "";
cf.innerHTML = '<option value="all">All chains</option>' + chains.map(c => '<option value="' + esc(c) + '">' + esc(c) + "</option>").join("");
} else {
cf.style.display = "none";
cf.innerHTML = "";
}
chainFilter = "all";
cf.value = "all";
renderFindings(findings);
}
function stat(k, v) { return '<div class="stat"><span class="k">' + esc(k) + '</span><span class="v">' + v + "</span></div>"; }
// ── Score trend chart (SVG line chart, no external deps) ──
function renderTrend(points) {
const el = document.getElementById("trend");
if (!points || points.length < 2) {
el.innerHTML = '<div class="empty"><span class="big">📈</span>Not enough history yet. Run audits with <code>--enable-history</code> to track trends.</div>';
return;
}
const W = 660, H = 180, PAD = 34;
const min = Math.min(...points.map(p => p.overall_score)) - 5;
const max = Math.max(...points.map(p => p.overall_score)) + 5;
const lo = Math.max(0, Math.floor(min / 10) * 10);
const hi = Math.min(100, Math.ceil(max / 10) * 10);
const x = i => PAD + i * (W - PAD * 2) / Math.max(points.length - 1, 1);
const y = s => H - PAD - (s - lo) / (hi - lo) * (H - PAD * 2);
const line = points.map((p, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(p.overall_score).toFixed(1)).join(" ");
const dots = points.map((p, i) =>
'<circle cx="' + x(i).toFixed(1) + '" cy="' + y(p.overall_score).toFixed(1) + '" r="4" fill="' + scoreColor(p.overall_score) + '">' +
'<title>' + esc(p.timestamp) + " — score " + p.overall_score + " — " + p.total_findings + " findings</title></circle>").join("");
// Y-axis gridlines + labels (every 20 points on the score range)
let grid = "";
for (let v = lo; v <= hi; v += 20) {
const gy = y(v);
grid += '<line x1="' + PAD + '" y1="' + gy.toFixed(1) + '" x2="' + (W - PAD) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-dasharray="2 4"/>' +
'<text x="4" y="' + (gy + 3).toFixed(1) + '" fill="var(--muted)" font-size="10">' + v + "</text>";
}
// X-axis labels (first, last, and every 4th point)
let labels = "";
points.forEach((p, i) => {
if (i === 0 || i === points.length - 1 || i % 4 === 0) {
const ts = new Date(p.timestamp);
const label = isNaN(ts) ? "" : (ts.getMonth() + 1) + "/" + ts.getDate();
labels += '<text x="' + x(i).toFixed(1) + '" y="' + (H - 10) + '" text-anchor="middle" fill="var(--muted)" font-size="10">' + esc(label) + "</text>";
}
});
el.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto">' +
grid +
'<path d="' + line + '" fill="none" stroke="var(--accent)" stroke-width="2.5"/>' +
dots + labels + "</svg>";
}
function loadHistory() {
fetch("/api/history").then(r => r.ok ? r.json() : []).then(renderTrend).catch(() => {});
}
function sevBar(sev, n, total) {
const pct = total ? Math.round(n / total * 100) : 0;
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>";
}
function renderFindings(findings) {
const q = (document.getElementById("search").value || "").toLowerCase();
const list = findings.filter(f => {
if (sevFilter !== "all" && severityOf(f) !== sevFilter) return false;
if (chainFilter !== "all" && (f.chain || "") !== chainFilter) return false;
if (q) {
const hay = (f.title + " " + (f.file || "") + " " + f.id + " " + (f.description || "")).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
const el = document.getElementById("findings");
if (!list.length) {
el.innerHTML = '<div class="empty"><span class="big">🔍</span>No findings match</div>';
return;
}
el.innerHTML = list.map(f => {
const sev = severityOf(f);
const loc = f.file ? (esc(f.file) + (f.line ? ":" + f.line : "")) : "";
const chain = f.chain ? '<span class="chain-tag">⛓️ ' + esc(f.chain) + "</span>" : "";
const refs = (f.references || []).length ? "<h4>References</h4><ul>" + f.references.map(r => "<li>" + esc(r) + "</li>").join("") + "</ul>" : "";
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>" : "";
return '<div class="finding" data-idx="' + findings.indexOf(f) + '">' +
'<div class="head"><span class="sev" style="background:' + SEV_COLORS[sev] + '">' + sev + "</span>" +
'<span class="title">' + esc(f.title) + "</span>" +
'<span class="loc">' + loc + '</span><span class="arrow">▶</span></div>' +
'<div class="body">' + chain +
"<h4>Description</h4><p>" + esc(f.description || "—") + "</p>" +
(f.code_snippet ? "<h4>Code</h4><pre>" + esc(f.code_snippet) + "</pre>" : "") +
"<h4>Recommendation</h4><p>" + esc(f.recommendation || "—") + "</p>" +
(f.blocks_deployment ? '<p style="color:var(--crit);margin-top:8px">⛔ Blocks deployment</p>' : "") +
exploit + refs + "</div></div>";
}).join("");
el.querySelectorAll(".finding .head").forEach(h => {
h.addEventListener("click", () => h.parentElement.classList.toggle("open"));
});
}
document.querySelectorAll(".chip").forEach(chip => {
chip.addEventListener("click", () => {
document.querySelectorAll(".chip").forEach(c => c.classList.remove("active"));
chip.classList.add("active");
sevFilter = chip.dataset.sev;
if (result) renderFindings(result.findings || []);
});
});
document.getElementById("search").addEventListener("input", () => result && renderFindings(result.findings || []));
document.getElementById("chainFilter").addEventListener("change", e => {
chainFilter = e.target.value;
if (result) renderFindings(result.findings || []);
});
function apply(data) {
result = data;
document.getElementById("updated").textContent = new Date().toLocaleTimeString();
render();
loadHistory(); // re-fetch trends whenever a new audit arrives
}
// Initial load + WebSocket live updates.
loadHistory();
fetch("/api/audit").then(r => r.ok ? r.json() : null).then(d => { if (d) apply(d); }).catch(() => {});
const wsProto = location.protocol === "https:" ? "wss" : "ws";
let ws = null;
function connect() {
ws = new WebSocket(wsProto + "://" + location.host + "/ws");
ws.onopen = () => { document.getElementById("conn").classList.add("on"); document.getElementById("connText").textContent = "live"; };
ws.onmessage = e => { try { apply(JSON.parse(e.data)); } catch (_) {} };
ws.onclose = () => { document.getElementById("conn").classList.remove("on"); document.getElementById("connText").textContent = "reconnecting…"; setTimeout(connect, 2000); };
ws.onerror = () => ws.close();
}
connect();
</script>
</body>
</html>
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dashboard_html_has_core_elements() {
assert!(DASHBOARD_HTML.contains("Forge Guard"));
assert!(DASHBOARD_HTML.contains("gauge"));
assert!(DASHBOARD_HTML.contains("WebSocket"));
assert!(DASHBOARD_HTML.contains("/api/audit"));
assert!(DASHBOARD_HTML.contains("/ws"));
assert!(DASHBOARD_HTML.contains("severity"));
}
#[test]
fn test_dashboard_html_has_charts_and_filters() {
assert!(
DASHBOARD_HTML.contains("sevBars"),
"severity distribution chart"
);
assert!(DASHBOARD_HTML.contains("Category Scores"));
assert!(
DASHBOARD_HTML.contains("chainFilter"),
"chain filter for multi-chain"
);
assert!(
DASHBOARD_HTML.contains("exploit_path"),
"expandable exploit details"
);
}
#[test]
fn test_dashboard_html_has_score_trend_chart() {
assert!(
DASHBOARD_HTML.contains("Score Trend"),
"trend panel heading"
);
assert!(
DASHBOARD_HTML.contains("/api/history"),
"history API endpoint"
);
assert!(
DASHBOARD_HTML.contains("renderTrend"),
"trend rendering function"
);
assert!(
DASHBOARD_HTML.contains("loadHistory"),
"history loader wired in"
);
assert!(
DASHBOARD_HTML.contains("--enable-history"),
"empty-state hint should mention --enable-history"
);
}
#[test]
fn test_dashboard_history_api_returns_json_points() {
let json = r#"[{"timestamp":"2026-08-17T10:00:00Z","overall_score":85,"risk_level":"LOW","total_findings":3,"critical_count":0,"high_count":1}]"#;
let v: serde_json::Value = serde_json::from_str(json).expect("valid history JSON");
let point = &v[0];
assert_eq!(point["overall_score"], 85);
assert_eq!(point["total_findings"], 3);
assert_eq!(point["high_count"], 1);
assert!(point["timestamp"].is_string());
assert!(point["risk_level"].is_string());
}
#[test]
fn test_dashboard_html_escapes_user_content() {
assert!(DASHBOARD_HTML.contains("replace(/[&<>\"']/g"));
}
}