Add dashboard docs and richer lab UI
这个提交包含在:
@@ -89,6 +89,9 @@ def validate(source_map: Dict[str, Any]) -> List[str]:
|
||||
GENERATED_DIR / "dashboard" / "profiles.json",
|
||||
GENERATED_DIR / "dashboard" / "assets" / "app.js",
|
||||
GENERATED_DIR / "dashboard" / "assets" / "styles.css",
|
||||
GENERATED_DIR / "dashboard" / "docs" / "project-features.html",
|
||||
GENERATED_DIR / "dashboard" / "docs" / "frontend-dashboard-design.html",
|
||||
GENERATED_DIR / "dashboard" / "docs" / "secure-code-index.html",
|
||||
ROOT / "08-threat-intel" / "registry" / "source-confidence.md",
|
||||
]:
|
||||
if not path.exists():
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from lab.config import ADVISORIES_DIR, CASE_RUNS_DIR, DASHBOARD_DIR, RUNS_DIR
|
||||
from lab.config import ADVISORIES_DIR, CASE_RUNS_DIR, DASHBOARD_DIR, ROOT, RUNS_DIR
|
||||
from lab.repro import load_profiles
|
||||
from lab.utils import ensure_dir, isoformat, load_json_dir, now_utc, unique, write_json, write_text
|
||||
|
||||
@@ -72,6 +72,15 @@ def _artifact_group(run: Dict[str, Any], key: str, label: str, refs: List[str],
|
||||
}
|
||||
|
||||
|
||||
def _attack_result_refs(run: Dict[str, Any]) -> List[str]:
|
||||
refs: List[str] = []
|
||||
for step in run.get("attack_steps", []):
|
||||
result_path = step.get("result_path")
|
||||
if result_path:
|
||||
refs.append(str(result_path))
|
||||
return unique(refs)
|
||||
|
||||
|
||||
def _progress_counts(run: Dict[str, Any]) -> Dict[str, int]:
|
||||
counts = {"completed": 0, "skipped": 0, "failed": 0, "blocked": 0, "planned": 0, "other": 0}
|
||||
for item in run.get("timeline", []):
|
||||
@@ -148,6 +157,132 @@ def _reasoning_lines(advisory: Dict[str, Any], profile: Dict[str, Any]) -> List[
|
||||
return unique(notes)
|
||||
|
||||
|
||||
def _dashboard_doc_page(title: str, source_path: Path, description: str) -> str:
|
||||
source_label = source_path.relative_to(ROOT) if source_path.is_relative_to(ROOT) else source_path
|
||||
body = source_path.read_text(encoding="utf-8") if source_path.exists() else f"missing source: {source_path}"
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #08111f;
|
||||
--panel: rgba(9, 18, 32, 0.9);
|
||||
--border: rgba(137, 171, 214, 0.2);
|
||||
--text: #f7fafc;
|
||||
--muted: #9fb3ca;
|
||||
--accent: #5eead4;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(94, 234, 212, 0.12), transparent 26%),
|
||||
linear-gradient(160deg, #050c16 0%, #091526 50%, #10233d 100%);
|
||||
}}
|
||||
main {{
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 40px;
|
||||
}}
|
||||
.panel {{
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 24px 80px rgba(1, 7, 20, 0.45);
|
||||
}}
|
||||
.actions {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}}
|
||||
.chip {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,0.05);
|
||||
text-decoration: none;
|
||||
}}
|
||||
.chip:hover {{ border-color: rgba(94, 234, 212, 0.42); }}
|
||||
h1 {{
|
||||
margin: 0 0 12px;
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
line-height: 1.08;
|
||||
}}
|
||||
.meta {{
|
||||
color: var(--muted);
|
||||
margin-bottom: 18px;
|
||||
}}
|
||||
pre {{
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(137, 171, 214, 0.12);
|
||||
background: rgba(2, 8, 22, 0.84);
|
||||
color: #d6e5f5;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="panel">
|
||||
<div class="actions">
|
||||
<a class="chip" href="../index.html">Back to dashboard</a>
|
||||
</div>
|
||||
<h1>{html.escape(title)}</h1>
|
||||
<div class="meta">{html.escape(description)} | source: {html.escape(str(source_label))}</div>
|
||||
<pre>{html.escape(body)}</pre>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _write_dashboard_docs() -> None:
|
||||
docs_dir = DASHBOARD_DIR / "docs"
|
||||
ensure_dir(docs_dir)
|
||||
docs = [
|
||||
(
|
||||
"project-features.html",
|
||||
"项目功能与特性总览",
|
||||
ROOT / "docs" / "project-features.md",
|
||||
"Dashboard-local mirror of the repo feature guide.",
|
||||
),
|
||||
(
|
||||
"frontend-dashboard-design.html",
|
||||
"本地前端工作台设计文档",
|
||||
ROOT / "docs" / "frontend-dashboard-design.md",
|
||||
"Dashboard-local mirror of the UI and interaction specification.",
|
||||
),
|
||||
(
|
||||
"secure-code-index.html",
|
||||
"安全编码修复库索引",
|
||||
ROOT / "05-defense" / "secure-code" / "INDEX.md",
|
||||
"Dashboard-local mirror of the secure-code library index.",
|
||||
),
|
||||
]
|
||||
for filename, title, source_path, description in docs:
|
||||
write_text(docs_dir / filename, _dashboard_doc_page(title, source_path, description))
|
||||
|
||||
|
||||
def render_run(run: Dict[str, Any]) -> Dict[str, str]:
|
||||
run_dir = CASE_RUNS_DIR / run["run_id"]
|
||||
ensure_dir(run_dir / "assets")
|
||||
@@ -310,6 +445,7 @@ def render_run(run: Dict[str, Any]) -> Dict[str, str]:
|
||||
|
||||
def render_dashboard() -> Dict[str, str]:
|
||||
ensure_dir(DASHBOARD_DIR)
|
||||
_write_dashboard_docs()
|
||||
advisory_records = load_json_dir(ADVISORIES_DIR)
|
||||
runs = load_json_dir(RUNS_DIR)
|
||||
advisory_map = {item["canonical_id"]: item for item in advisory_records if item.get("canonical_id")}
|
||||
@@ -378,12 +514,19 @@ def render_dashboard() -> Dict[str, str]:
|
||||
cloned = dict(item)
|
||||
advisory = advisory_map.get(item["advisory_id"], {})
|
||||
profile = profile_map.get(item["repro_profile_id"], {})
|
||||
browser_evidence = item.get("browser_evidence") or advisory.get("browser_evidence") or {
|
||||
"required": bool(profile.get("browser_assertions", {}).get("required")),
|
||||
"present": False,
|
||||
"refs": [],
|
||||
}
|
||||
request_only_refs = [ref for ref in item.get("request_log_refs", []) if ref not in item.get("baseline_refs", [])]
|
||||
cloned["dashboard_refs"] = {
|
||||
"report_html": f"./runs/{item['run_id']}/report.html",
|
||||
"report_md": f"./runs/{item['run_id']}/report.md",
|
||||
"timeline": f"./runs/{item['run_id']}/timeline.mmd",
|
||||
"bundle": f"./runs/{item['run_id']}/run.json",
|
||||
}
|
||||
cloned["browser_evidence"] = browser_evidence
|
||||
cloned["browser_links"] = [_dashboard_ref(item, ref) for ref in item.get("browser_refs", [])]
|
||||
cloned["container_links"] = [_dashboard_ref(item, ref) for ref in item.get("container_log_refs", [])]
|
||||
cloned["request_links"] = [_dashboard_ref(item, ref) for ref in item.get("request_log_refs", [])]
|
||||
@@ -405,9 +548,11 @@ def render_dashboard() -> Dict[str, str]:
|
||||
use_dashboard_refs=True,
|
||||
),
|
||||
_artifact_group(item, "compose", "Compose", item.get("compose_refs", [])),
|
||||
_artifact_group(item, "baseline", "Baseline Snapshots", item.get("baseline_refs", [])),
|
||||
_artifact_group(item, "attack", "Attack Outputs", _attack_result_refs(item)),
|
||||
_artifact_group(item, "browser", "Browser Evidence", item.get("browser_refs", [])),
|
||||
_artifact_group(item, "container", "Container Logs", item.get("container_log_refs", [])),
|
||||
_artifact_group(item, "requests", "Request Logs", item.get("request_log_refs", [])),
|
||||
_artifact_group(item, "requests", "Request Logs", request_only_refs),
|
||||
]
|
||||
cloned["artifact_groups"] = [group for group in cloned["artifact_groups"] if group["count"]]
|
||||
decorated_runs.append(cloned)
|
||||
@@ -842,6 +987,70 @@ button, input, select {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.progress-strip {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(159, 179, 202, 0.14);
|
||||
}
|
||||
|
||||
.progress-segment {
|
||||
min-width: 10px;
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.progress-completed { background: linear-gradient(90deg, rgba(110, 231, 165, 0.9), rgba(94, 234, 212, 0.9)); }
|
||||
.progress-blocked { background: linear-gradient(90deg, rgba(255, 123, 123, 0.95), rgba(255, 160, 122, 0.9)); }
|
||||
.progress-failed { background: linear-gradient(90deg, rgba(255, 123, 123, 0.92), rgba(255, 209, 102, 0.88)); }
|
||||
.progress-skipped { background: linear-gradient(90deg, rgba(255,255,255,0.22), rgba(159, 179, 202, 0.3)); }
|
||||
.progress-planned { background: linear-gradient(90deg, rgba(144, 205, 244, 0.82), rgba(94, 234, 212, 0.72)); }
|
||||
.progress-other { background: linear-gradient(90deg, rgba(255,255,255,0.18), rgba(255,255,255,0.1)); }
|
||||
|
||||
.progress-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress-legend .tag {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.progress-legend .swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.stage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.stage-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(159, 179, 202, 0.16);
|
||||
}
|
||||
|
||||
.stage-card strong {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1278,6 +1487,7 @@ function renderRunList() {
|
||||
const active = item.run_id === state.selectedRunId ? "is-active" : "";
|
||||
const title = item.advisory_meta?.title || item.advisory_id;
|
||||
const reasoning = item.reasoning_lines?.[0] || item.blocked_reason || "";
|
||||
const browserLabel = item.browser_evidence?.present ? "ready" : (item.browser_evidence?.required ? "required" : "n/a");
|
||||
return `
|
||||
<button class="run-card ${active}" data-run-id="${escapeHtml(item.run_id)}">
|
||||
<div class="run-card-top">
|
||||
@@ -1289,7 +1499,7 @@ function renderRunList() {
|
||||
<div class="tag-row" style="margin-top:10px;">
|
||||
<span class="tag">timeline ${escapeHtml(item.timeline?.length || 0)}</span>
|
||||
<span class="tag">artifacts ${escapeHtml((item.artifact_groups || []).reduce((sum, group) => sum + group.count, 0))}</span>
|
||||
<span class="tag">browser ${item.browser_evidence?.present ? "ready" : "missing"}</span>
|
||||
<span class="tag">browser ${escapeHtml(browserLabel)}</span>
|
||||
</div>
|
||||
<div class="mini-muted" style="margin-top:10px;">${escapeHtml(reasoning)}</div>
|
||||
</button>
|
||||
@@ -1343,7 +1553,7 @@ function hydrateFilterOptions() {
|
||||
}
|
||||
|
||||
function defaultArtifact(run) {
|
||||
const preference = ["requests", "container", "browser", "compose", "reports"];
|
||||
const preference = ["attack", "requests", "container", "browser", "baseline", "compose", "reports"];
|
||||
for (const key of preference) {
|
||||
const group = (run.artifact_groups || []).find((item) => item.key === key && item.items?.length);
|
||||
if (!group) continue;
|
||||
@@ -1353,6 +1563,73 @@ function defaultArtifact(run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function totalProgress(progress) {
|
||||
const values = Object.values(progress || {}).map((value) => Number(value || 0));
|
||||
return values.reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
function renderProgressStrip(progress) {
|
||||
const total = totalProgress(progress);
|
||||
if (!total) {
|
||||
return `
|
||||
<div class="progress-strip">
|
||||
<div class="progress-bar"><div class="progress-segment progress-other" style="width:100%"></div></div>
|
||||
<div class="mini-muted">No timeline progress recorded.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const order = [
|
||||
["completed", "Completed", "progress-completed"],
|
||||
["blocked", "Blocked", "progress-blocked"],
|
||||
["failed", "Failed", "progress-failed"],
|
||||
["skipped", "Skipped", "progress-skipped"],
|
||||
["planned", "Planned", "progress-planned"],
|
||||
["other", "Other", "progress-other"],
|
||||
];
|
||||
const segments = order
|
||||
.filter(([key]) => Number(progress?.[key] || 0) > 0)
|
||||
.map(([key, _label, klass]) => {
|
||||
const count = Number(progress?.[key] || 0);
|
||||
const pct = Math.max((count / total) * 100, 4);
|
||||
return `<div class="progress-segment ${klass}" style="width:${pct}%"></div>`;
|
||||
})
|
||||
.join("");
|
||||
const legend = order
|
||||
.filter(([key]) => Number(progress?.[key] || 0) > 0)
|
||||
.map(([key, label, klass]) => `
|
||||
<span class="tag">
|
||||
<span class="swatch ${klass}"></span>
|
||||
${escapeHtml(label)} ${escapeHtml(progress?.[key] || 0)}
|
||||
</span>
|
||||
`)
|
||||
.join("");
|
||||
return `
|
||||
<div class="progress-strip">
|
||||
<div class="progress-bar">${segments}</div>
|
||||
<div class="progress-legend">${legend}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStageCards(run) {
|
||||
const timeline = run.timeline || [];
|
||||
if (!timeline.length) {
|
||||
return `<div class="empty-state">No stage records available.</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="stage-grid">
|
||||
${timeline.map((item) => `
|
||||
<article class="stage-card">
|
||||
<strong>${escapeHtml(item.step || "-")}</strong>
|
||||
<div class="${statusClass(item.status || "default")}">${escapeHtml(item.status || "unknown")}</div>
|
||||
<div class="mini-muted" style="margin-top:10px;">${escapeHtml(item.detail || "-")}</div>
|
||||
<div class="mini-muted" style="margin-top:8px;">${escapeHtml(item.at || "-")}</div>
|
||||
</article>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function openArtifact(href, label, kind) {
|
||||
state.selectedArtifact = { href, label, kind };
|
||||
document.querySelectorAll(".artifact-button").forEach((button) => {
|
||||
@@ -1409,6 +1686,7 @@ function renderDetail() {
|
||||
<span class="tag">${escapeHtml(run.repro_profile_id)}</span>
|
||||
<span class="tag">${escapeHtml(run.artifact_mode)}</span>
|
||||
<span class="tag">${escapeHtml(run.verification_mode)}</span>
|
||||
<span class="tag">${escapeHtml(run.target_env || "local-docker")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="detail-headline">${escapeHtml(advisory.title || run.advisory_id)}</h2>
|
||||
@@ -1417,11 +1695,12 @@ function renderDetail() {
|
||||
<a class="chip" href="${escapeHtml(run.dashboard_refs.report_html)}" target="_blank" rel="noreferrer">Open HTML report</a>
|
||||
<a class="ghost-chip" href="${escapeHtml(run.dashboard_refs.report_md)}" target="_blank" rel="noreferrer">Open Markdown</a>
|
||||
<a class="ghost-chip" href="${escapeHtml(run.dashboard_refs.bundle)}" target="_blank" rel="noreferrer">Open run JSON</a>
|
||||
<a class="ghost-chip" href="./docs/frontend-dashboard-design.html" target="_blank" rel="noreferrer">Open UI spec</a>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card"><strong>Timeline Steps</strong><span>${escapeHtml(run.timeline?.length || 0)}</span></article>
|
||||
<article class="stat-card"><strong>Artifacts</strong><span>${escapeHtml((run.artifact_groups || []).reduce((sum, group) => sum + group.count, 0))}</span></article>
|
||||
<article class="stat-card"><strong>Browser</strong><span>${run.browser_evidence?.present ? "Ready" : "Missing"}</span></article>
|
||||
<article class="stat-card"><strong>Browser</strong><span>${run.browser_evidence?.present ? "Ready" : (run.browser_evidence?.required ? "Required" : "Optional")}</span></article>
|
||||
<article class="stat-card"><strong>Finished</strong><span>${escapeHtml(timeAgo(run.finished_at))}</span></article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1431,12 +1710,8 @@ function renderDetail() {
|
||||
<details class="glass-panel accordion" open>
|
||||
<summary><span>Progress Timeline</span><span class="tag">${escapeHtml(run.timeline?.length || 0)} steps</span></summary>
|
||||
<div class="accordion-content">
|
||||
<div class="tag-row" style="margin-bottom:14px;">
|
||||
<span class="tag">completed ${escapeHtml(run.progress?.completed || 0)}</span>
|
||||
<span class="tag">blocked ${escapeHtml(run.progress?.blocked || 0)}</span>
|
||||
<span class="tag">skipped ${escapeHtml(run.progress?.skipped || 0)}</span>
|
||||
<span class="tag">failed ${escapeHtml(run.progress?.failed || 0)}</span>
|
||||
</div>
|
||||
${renderProgressStrip(run.progress)}
|
||||
${renderStageCards(run)}
|
||||
<div class="timeline-list">
|
||||
${(run.timeline || []).map((item) => `
|
||||
<article class="timeline-item">
|
||||
@@ -1533,7 +1808,7 @@ function renderDetail() {
|
||||
${(advisory.secondary_source_urls || []).map((ref) => `<a href="${escapeHtml(ref)}" target="_blank" rel="noreferrer">${escapeHtml(ref)}</a>`).join("")}
|
||||
</div>
|
||||
<div class="tag-row" style="margin-top:16px;">
|
||||
${(advisory.secure_code_topics || []).map((topic) => `<span class="tag">${escapeHtml(topic)}</span>`).join("")}
|
||||
${(advisory.secure_code_topics || []).map((topic) => `<a class="tag" href="./docs/secure-code-index.html" target="_blank" rel="noreferrer">${escapeHtml(topic)}</a>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1638,6 +1913,7 @@ document.addEventListener("DOMContentLoaded", init);
|
||||
<button id="refreshDashboard" class="chip" type="button">Refresh Dashboard</button>
|
||||
<label class="ghost-chip"><input id="autoRefresh" type="checkbox" checked> Auto Refresh</label>
|
||||
<a class="ghost-chip" href="./summary.json" target="_blank" rel="noreferrer">Open Summary JSON</a>
|
||||
<a class="ghost-chip" href="./docs/project-features.html" target="_blank" rel="noreferrer">Open Feature Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户