Add dashboard docs and richer lab UI
这个提交包含在:
@@ -169,6 +169,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">
|
||||
@@ -180,7 +181,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>
|
||||
@@ -234,7 +235,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;
|
||||
@@ -244,6 +245,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) => {
|
||||
@@ -300,6 +368,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>
|
||||
@@ -308,11 +377,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>
|
||||
@@ -322,12 +392,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">
|
||||
@@ -424,7 +490,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>
|
||||
|
||||
@@ -400,6 +400,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;
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户