Merge pull request #1028 from pikasTech/feat/1025-vue-ts-dashboard
feat: Web哨兵监控面板桌面端视图改进三栏布局·低噪声·渐进披露 (#1025)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
|
||||
// Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering.
|
||||
// Desktop view redesign (issue #1025): 三栏 master-detail、低噪声、渐进披露、排查直觉化。
|
||||
// 保持纯 vanilla JS + 原生 CSS,无框架、无构建步骤,所有渲染通过 innerHTML 拼接。
|
||||
|
||||
/**
|
||||
* @typedef {{runId?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, stateRoot?: string|null, source?: string|null}} Traceability
|
||||
@@ -20,14 +22,20 @@ const refs = {
|
||||
manualRefresh: document.getElementById("manual-refresh"),
|
||||
autoRefreshEnabled: document.getElementById("auto-refresh-enabled"),
|
||||
autoRefreshInterval: document.getElementById("auto-refresh-interval"),
|
||||
overall: document.getElementById("metric-overall"),
|
||||
origin: document.getElementById("metric-origin"),
|
||||
latestRun: document.getElementById("metric-latest-run"),
|
||||
latestAge: document.getElementById("metric-latest-age"),
|
||||
findings: document.getElementById("metric-findings"),
|
||||
findingsNote: document.getElementById("metric-findings-note"),
|
||||
scheduler: document.getElementById("metric-scheduler"),
|
||||
budget: document.getElementById("metric-budget"),
|
||||
latestRun: document.getElementById("latest-run"),
|
||||
filterRed: document.getElementById("filter-red"),
|
||||
originNote: document.getElementById("sentinel-origin-note"),
|
||||
statusSummary: document.getElementById("status-summary"),
|
||||
summaryStatus: document.getElementById("summary-status"),
|
||||
summaryLatest: document.getElementById("summary-latest"),
|
||||
summaryLatestAge: document.getElementById("summary-latest-age"),
|
||||
summaryFindings: document.getElementById("summary-findings"),
|
||||
summaryFindingsNote: document.getElementById("summary-findings-note"),
|
||||
summaryScheduler: document.getElementById("summary-scheduler"),
|
||||
summaryBudget: document.getElementById("summary-budget"),
|
||||
summaryChecks: document.getElementById("summary-checks"),
|
||||
overviewChecks: document.getElementById("overview-checks"),
|
||||
checkSummaryPill: document.getElementById("check-summary-pill"),
|
||||
checkConfig: document.getElementById("check-config"),
|
||||
checkPvc: document.getElementById("check-pvc"),
|
||||
checkAnalyzer: document.getElementById("check-analyzer"),
|
||||
@@ -35,6 +43,8 @@ const refs = {
|
||||
checkMaintenance: document.getElementById("check-maintenance"),
|
||||
timeline: document.getElementById("run-timeline"),
|
||||
timelineCount: document.getElementById("timeline-count"),
|
||||
timelineToggle: document.getElementById("timeline-toggle"),
|
||||
runsFilterSummary: document.getElementById("runs-filter-summary"),
|
||||
filterForm: document.getElementById("runs-filter"),
|
||||
filterStatus: document.getElementById("filter-status"),
|
||||
filterSeverity: document.getElementById("filter-severity"),
|
||||
@@ -44,6 +54,7 @@ const refs = {
|
||||
clearFilters: document.getElementById("clear-filters"),
|
||||
runsBody: document.getElementById("runs-body"),
|
||||
runsCount: document.getElementById("runs-count"),
|
||||
findingsFilterSummary: document.getElementById("findings-filter-summary"),
|
||||
findingsFilterForm: document.getElementById("findings-filter"),
|
||||
findingFilterSeverity: document.getElementById("finding-filter-severity"),
|
||||
findingFilterWindow: document.getElementById("finding-filter-window"),
|
||||
@@ -53,8 +64,11 @@ const refs = {
|
||||
findingAggregation: document.getElementById("finding-aggregation"),
|
||||
findingsList: document.getElementById("findings-list"),
|
||||
findingsCount: document.getElementById("findings-count"),
|
||||
findingsDrilldown: document.getElementById("findings-drilldown"),
|
||||
detailTabs: document.getElementById("detail-tabs"),
|
||||
detailSubtitle: document.getElementById("detail-subtitle"),
|
||||
detailContent: document.getElementById("detail-content"),
|
||||
copyToast: document.getElementById("copy-toast"),
|
||||
};
|
||||
|
||||
const state = {
|
||||
@@ -73,11 +87,21 @@ const state = {
|
||||
lastUpdatedAt: null,
|
||||
filters: readFiltersFromLocation(),
|
||||
findingFilters: readFindingFiltersFromLocation(),
|
||||
selectedTab: readTabFromLocation(),
|
||||
selectedFindingCode: new URLSearchParams(window.location.search).get("finding") || "",
|
||||
findingDrilldownKey: "",
|
||||
runsFilterOpen: false,
|
||||
findingsFilterOpen: false,
|
||||
checksExpanded: false,
|
||||
timelineCollapsed: false,
|
||||
pausedByInteraction: false,
|
||||
};
|
||||
|
||||
const DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"];
|
||||
|
||||
const dashboardLimits = {
|
||||
timeline: { mobile: 6, tablet: 9, desktop: 12 },
|
||||
runs: { mobile: 8, tablet: 12, desktop: 24 },
|
||||
timeline: { mobile: 6, tablet: 9, desktop: 16 },
|
||||
runs: { mobile: 8, tablet: 12, desktop: 30 },
|
||||
findingsRed: { mobile: 4, tablet: 6, desktop: 8 },
|
||||
findingsOther: { mobile: 2, tablet: 3, desktop: 5 },
|
||||
};
|
||||
@@ -88,12 +112,18 @@ const autoRefresh = createAutoRefresh({
|
||||
intervals: [5, 10, 30],
|
||||
defaultInterval: 10,
|
||||
onRefresh: () => loadDashboard({ silent: true }),
|
||||
shouldPause: () => document.hidden || state.loading,
|
||||
shouldPause: () => document.hidden || state.loading || state.pausedByInteraction,
|
||||
});
|
||||
|
||||
refs.manualRefresh.addEventListener("click", () => loadDashboard({ silent: false }));
|
||||
refs.autoRefreshEnabled.addEventListener("change", () => autoRefresh.setEnabled(refs.autoRefreshEnabled.checked));
|
||||
refs.autoRefreshInterval.addEventListener("change", () => autoRefresh.setInterval(Number(refs.autoRefreshInterval.value)));
|
||||
refs.latestRun.addEventListener("click", () => jumpToLatestRun());
|
||||
refs.filterRed.addEventListener("click", () => toggleRedOnly());
|
||||
refs.runsFilterSummary.addEventListener("click", () => toggleRunsFilter());
|
||||
refs.findingsFilterSummary.addEventListener("click", () => toggleFindingsFilter());
|
||||
refs.checkSummaryPill.addEventListener("click", () => toggleChecks());
|
||||
refs.timelineToggle.addEventListener("click", () => toggleTimeline());
|
||||
refs.filterForm.addEventListener("submit", (event) => event.preventDefault());
|
||||
for (const control of [refs.filterStatus, refs.filterSeverity, refs.filterWindow, refs.filterSort]) {
|
||||
control.addEventListener("change", () => applyFilterControls());
|
||||
@@ -102,6 +132,7 @@ refs.filterSearch.addEventListener("input", debounce(() => applyFilterControls()
|
||||
refs.clearFilters.addEventListener("click", () => {
|
||||
state.filters = { status: "", severity: "", window: "", search: "", sort: "updated" };
|
||||
writeFiltersToControls();
|
||||
updateRunsFilterSummary();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
});
|
||||
@@ -114,23 +145,34 @@ for (const control of [refs.findingFilterCode, refs.findingFilterScenario]) {
|
||||
}
|
||||
refs.findingClearFilters.addEventListener("click", () => {
|
||||
state.findingFilters = { severity: "", window: "24h", code: "", scenario: "" };
|
||||
state.findingDrilldownKey = "";
|
||||
writeFindingFiltersToControls();
|
||||
updateFindingsFilterSummary();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
});
|
||||
refs.detailTabs.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-detail-tab]");
|
||||
if (button) selectTab(button.dataset.detailTab);
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && autoRefresh.enabled()) loadDashboard({ silent: true });
|
||||
});
|
||||
window.addEventListener("resize", debounce(() => {
|
||||
if (!state.overview) return;
|
||||
renderTimeline();
|
||||
renderRuns();
|
||||
renderFindings();
|
||||
}, 150));
|
||||
document.addEventListener("keydown", handleKeyboardShortcut);
|
||||
window.addEventListener("resize", debounce(updateViewportClass, 150));
|
||||
|
||||
// 交互暂停:hover runs 表格或在 detail tab 阅读时暂停 auto-refresh
|
||||
refs.runsBody.addEventListener("mouseenter", () => { state.pausedByInteraction = true; }, true);
|
||||
refs.runsBody.addEventListener("mouseleave", () => { state.pausedByInteraction = false; }, true);
|
||||
refs.detailContent.addEventListener("mouseenter", () => { state.pausedByInteraction = true; }, true);
|
||||
refs.detailContent.addEventListener("mouseleave", () => { state.pausedByInteraction = false; }, true);
|
||||
|
||||
hydrateControls();
|
||||
writeFiltersToControls();
|
||||
writeFindingFiltersToControls();
|
||||
updateRunsFilterSummary();
|
||||
updateFindingsFilterSummary();
|
||||
updateViewportClass();
|
||||
loadDashboard({ silent: false }).catch((error) => renderError(error));
|
||||
|
||||
function createDashboardApi() {
|
||||
@@ -271,6 +313,7 @@ async function selectRun(runId) {
|
||||
syncLocationQuery();
|
||||
renderRuns();
|
||||
refs.detailSubtitle.textContent = runId;
|
||||
refs.detailTabs.hidden = false;
|
||||
refs.detailContent.innerHTML = '<div class="empty-state">加载中</div>';
|
||||
try {
|
||||
const [detail, views] = await Promise.all([
|
||||
@@ -309,45 +352,68 @@ function renderOverview() {
|
||||
const latestStatus = latest?.status || status;
|
||||
refs.statusPill.textContent = displayStatus(latestStatus);
|
||||
refs.statusPill.className = `status-pill ${statusClass(latestStatus)}`;
|
||||
refs.overall.textContent = displayStatus(latestStatus);
|
||||
refs.origin.textContent = `${overview.publicOrigin || root.dataset.publicOrigin || "-"} · 历史 ${displayStatus(status)}`;
|
||||
|
||||
refs.latestRun.textContent = latest?.runId || latest?.id || "-";
|
||||
refs.latestAge.textContent = latest?.updatedAt ? `${formatRelative(latest.updatedAt)} 更新` : "-";
|
||||
// 状态摘要条合并 status-pill 信息(B1 去重:不再单独显示 metric-overall 重复值)
|
||||
refs.statusSummary.hidden = false;
|
||||
refs.summaryStatus.textContent = displayStatus(latestStatus);
|
||||
refs.summaryStatus.className = statusClass(latestStatus);
|
||||
refs.originNote.textContent = overview.publicOrigin || root.dataset.publicOrigin || "";
|
||||
|
||||
refs.summaryLatest.textContent = latest?.runId || latest?.id || "-";
|
||||
refs.summaryLatestAge.textContent = latest?.updatedAt ? `${formatRelative(latest.updatedAt)} 更新` : "-";
|
||||
|
||||
const severityCounts = overview.severityCounts || {};
|
||||
const totalFindings = Object.values(severityCounts).reduce((sum, value) => sum + Number(value || 0), 0);
|
||||
refs.findings.textContent = formatNumber(totalFindings);
|
||||
refs.findingsNote.textContent = formatSeveritySummary(severityCounts);
|
||||
refs.summaryFindings.textContent = formatNumber(totalFindings);
|
||||
refs.summaryFindingsNote.textContent = formatSeveritySummary(severityCounts);
|
||||
|
||||
const scheduler = overview.scheduler || {};
|
||||
const heartbeat = scheduler.heartbeat || {};
|
||||
refs.scheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "未知";
|
||||
refs.summaryScheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "未知";
|
||||
const maxSeconds = overview.targetValidation?.maxSeconds ?? 120;
|
||||
refs.budget.textContent = `targetValidation 预算 ${maxSeconds}s`;
|
||||
refs.summaryBudget.textContent = `预算 ${maxSeconds}s`;
|
||||
|
||||
const checks = overview.health?.checks || {};
|
||||
const checkResults = [
|
||||
{ label: "配置", ok: checks.config?.ok, detail: checks.config?.status },
|
||||
{ label: "PVC", ok: checks.pvc?.ok, detail: checks.pvc?.stateRoot },
|
||||
{ label: "分析器", ok: checks.analyzer?.ok, detail: "observe analyze" },
|
||||
{ label: "公开入口", ok: Boolean(overview.publicOrigin), detail: overview.publicOrigin || "-" },
|
||||
];
|
||||
const maintenance = overview.maintenance || {};
|
||||
checkResults.push({ label: "维护窗口", ok: maintenance.active !== true, detail: maintenance.active ? "生效中" : "未生效" });
|
||||
|
||||
const allOk = checkResults.every((item) => item.ok);
|
||||
const okCount = checkResults.filter((item) => item.ok).length;
|
||||
refs.summaryChecks.hidden = false;
|
||||
refs.checkSummaryPill.textContent = allOk ? `✓ ${okCount}/${checkResults.length} 检查通过` : `${okCount}/${checkResults.length} 检查`;
|
||||
refs.checkSummaryPill.className = `check-summary-pill ${allOk ? "check-ok" : "check-blocked"}`;
|
||||
refs.overviewChecks.hidden = false;
|
||||
|
||||
renderCheckChip(refs.checkConfig, "配置", checks.config?.ok, checks.config?.status);
|
||||
renderCheckChip(refs.checkPvc, "PVC", checks.pvc?.ok, checks.pvc?.stateRoot);
|
||||
renderCheckChip(refs.checkAnalyzer, "分析器", checks.analyzer?.ok, "observe analyze");
|
||||
renderCheckChip(refs.checkPublic, "公开入口", Boolean(overview.publicOrigin), overview.publicOrigin || "-");
|
||||
const maintenance = overview.maintenance || {};
|
||||
renderCheckChip(refs.checkMaintenance, "维护窗口", maintenance.active !== true, maintenance.active ? "生效中" : "未生效");
|
||||
|
||||
// B3: 全绿时默认折叠 overview checks 明细
|
||||
refs.overviewChecks.classList.toggle("overview-checks-collapsed", !state.checksExpanded);
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const visibleRuns = state.runs.slice(0, responsiveLimit(dashboardLimits.timeline));
|
||||
refs.timelineCount.textContent = `最近 ${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)} 次`;
|
||||
refs.timelineCount.textContent = `最近 ${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)}`;
|
||||
if (state.runs.length === 0) {
|
||||
refs.timeline.innerHTML = '<div class="empty-state">暂无时间线</div>';
|
||||
refs.timeline.innerHTML = '<div class="empty-state compact">暂无时间线</div>';
|
||||
return;
|
||||
}
|
||||
refs.timeline.innerHTML = visibleRuns.map((run) => {
|
||||
const runId = run.runId || run.id || "-";
|
||||
const title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${run.updatedAt ? formatRelative(run.updatedAt) : "-"}`;
|
||||
const age = run.updatedAt ? formatRelative(run.updatedAt) : "-";
|
||||
const title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${age}`;
|
||||
return `<button class="timeline-node ${statusClass(run.status)}" type="button" data-run-id="${escapeAttr(runId)}" title="${escapeAttr(title)}">
|
||||
<span class="timeline-dot"></span>
|
||||
<span class="timeline-label">${escapeHtml(displayStatus(run.status))}</span>
|
||||
<span class="timeline-age">${escapeHtml(age)}</span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
for (const node of refs.timeline.querySelectorAll("[data-run-id]")) {
|
||||
@@ -359,7 +425,7 @@ function renderRuns() {
|
||||
const visibleLimit = responsiveLimit(dashboardLimits.runs);
|
||||
const visibleRuns = state.runs.slice(0, visibleLimit);
|
||||
const hiddenCount = Math.max(0, state.runs.length - visibleRuns.length);
|
||||
refs.runsCount.textContent = `${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)} 条可见`;
|
||||
refs.runsCount.textContent = `${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)} 条`;
|
||||
if (state.runs.length === 0) {
|
||||
refs.runsBody.innerHTML = '<tr><td class="empty-state" colspan="5">暂无运行</td></tr>';
|
||||
return;
|
||||
@@ -367,17 +433,15 @@ function renderRuns() {
|
||||
refs.runsBody.innerHTML = visibleRuns.map((run) => {
|
||||
const runId = run.runId || run.id || "-";
|
||||
const selected = state.selectedRunId === runId ? " selected-row" : "";
|
||||
return `<tr class="${selected}" data-run-id="${escapeAttr(runId)}">
|
||||
<td data-label="运行"><div class="run-identity">
|
||||
<div><span>run</span><code>${escapeHtml(runId)}</code></div>
|
||||
<div><span>observer</span><code>${escapeHtml(run.observerId || "-")}</code></div>
|
||||
</div></td>
|
||||
const severityRowClass = run.maxSeverity ? ` ${severityClass(run.maxSeverity)}` : "";
|
||||
return `<tr class="${selected}${severityRowClass}" data-run-id="${escapeAttr(runId)}">
|
||||
<td data-label="运行"><div class="run-identity"><code>${escapeHtml(shortText(runId, 24))}</code><small>${escapeHtml(run.observerId ? shortText(run.observerId, 28) : "-")}</small></div></td>
|
||||
<td data-label="状态"><span class="status-pill ${statusClass(run.status)}">${escapeHtml(displayStatus(run.status))}</span></td>
|
||||
<td data-label="场景">${escapeHtml(run.scenarioId || "-")}</td>
|
||||
<td data-label="发现项">${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` <span class="severity-pill ${severityClass(run.maxSeverity)}">${escapeHtml(displaySeverity(run.maxSeverity))}</span>` : ""}</td>
|
||||
<td data-label="更新时间"><span>${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}</span><small>${escapeHtml(run.maintenance ? "维护窗口" : "")}</small></td>
|
||||
<td data-label="发现">${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` <span class="severity-pill ${severityClass(run.maxSeverity)}">${escapeHtml(displaySeverity(run.maxSeverity))}</span>` : ""}</td>
|
||||
<td data-label="更新">${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}${run.maintenance ? `<small>维护窗口</small>` : ""}</td>
|
||||
</tr>`;
|
||||
}).join("") + (hiddenCount > 0 ? `<tr class="limit-row"><td colspan="5">其余 ${formatNumber(hiddenCount)} 条运行通过筛选、搜索或 API drill-down 查看</td></tr>` : "");
|
||||
}).join("") + (hiddenCount > 0 ? `<tr class="limit-row"><td colspan="5">其余 ${formatNumber(hiddenCount)} 条通过筛选、搜索或 API drill-down 查看</td></tr>` : "");
|
||||
for (const row of refs.runsBody.querySelectorAll("tr[data-run-id]")) {
|
||||
row.addEventListener("click", () => selectRun(row.dataset.runId));
|
||||
}
|
||||
@@ -386,28 +450,99 @@ function renderRuns() {
|
||||
function renderFindings() {
|
||||
renderFindingAggregation();
|
||||
refs.findingsCount.textContent = `${formatNumber(state.findings.length)} 组`;
|
||||
if (state.findingDrilldownKey) {
|
||||
renderFindingsDrilldown();
|
||||
return;
|
||||
}
|
||||
refs.findingsList.hidden = false;
|
||||
refs.findingsDrilldown.hidden = true;
|
||||
if (state.findings.length === 0) {
|
||||
refs.findingsList.innerHTML = '<div class="empty-state">暂无发现项</div>';
|
||||
return;
|
||||
}
|
||||
// B8: 默认只显示聚合,list 折叠为 severity group summary(点击展开明细)
|
||||
const groups = groupedFindingsBySeverity(state.findings);
|
||||
refs.findingsList.innerHTML = groups.map((group, groupIndex) => {
|
||||
const open = group.key === "red" || groupIndex === 0;
|
||||
refs.findingsList.innerHTML = groups.map((group) => {
|
||||
const visibleItems = group.items.slice(0, findingVisibleLimit(group.key));
|
||||
const hiddenCount = Math.max(0, group.items.length - visibleItems.length);
|
||||
return `<details class="finding-group finding-group-${escapeAttr(group.key)}" ${open ? "open" : ""}>
|
||||
return `<details class="finding-group finding-group-${escapeAttr(group.key)}">
|
||||
<summary>
|
||||
<span>${escapeHtml(displaySeverity(group.key))}</span>
|
||||
<strong>${formatNumber(group.totalCount)}</strong>
|
||||
<small>${formatNumber(group.items.length)} 组${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组通过过滤查看` : ""}</small>
|
||||
<small>${formatNumber(group.items.length)} 组${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组` : ""}</small>
|
||||
</summary>
|
||||
<div class="finding-group-list">${visibleItems.map(renderFindingItem).join("")}</div>
|
||||
<div class="finding-group-list">${visibleItems.map((item) => renderFindingItemCollapsed(item)).join("")}</div>
|
||||
</details>`;
|
||||
}).join("");
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) {
|
||||
bindFindingItemEvents();
|
||||
}
|
||||
|
||||
function renderFindingsDrilldown() {
|
||||
refs.findingsList.hidden = true;
|
||||
refs.findingsDrilldown.hidden = false;
|
||||
const [filterKey, filterValue] = state.findingDrilldownKey.split(":", 2);
|
||||
const matched = state.findings.filter((item) => {
|
||||
if (filterKey === "severity") return String(item.severity || "unknown").toLowerCase() === filterValue;
|
||||
if (filterKey === "code") return (item.code || item.findingId || "unknown") === filterValue;
|
||||
if (filterKey === "scenario") return (item.scenarioId || "unknown") === filterValue;
|
||||
return false;
|
||||
});
|
||||
const label = drilldownLabel(filterKey, filterValue);
|
||||
refs.findingsDrilldown.innerHTML = `<button type="button" class="link-button findings-drilldown-back" id="findings-drilldown-back">← 返回聚合</button>
|
||||
<div class="view-note">drill-down: ${escapeHtml(label)} · ${formatNumber(matched.length)} 组</div>
|
||||
<div class="finding-group-list">${matched.map((item) => renderFindingItemCollapsed(item)).join("")}</div>`;
|
||||
document.getElementById("findings-drilldown-back")?.addEventListener("click", () => {
|
||||
state.findingDrilldownKey = "";
|
||||
syncLocationQuery();
|
||||
renderFindings();
|
||||
});
|
||||
bindFindingItemEvents(refs.findingsDrilldown);
|
||||
}
|
||||
|
||||
function drilldownLabel(filterKey, filterValue) {
|
||||
if (filterKey === "severity") return `严重级别=${displaySeverity(filterValue)}`;
|
||||
if (filterKey === "code") return `代码=${displayFindingCode(filterValue)}`;
|
||||
if (filterKey === "scenario") return `场景=${filterValue}`;
|
||||
return filterValue;
|
||||
}
|
||||
|
||||
function renderFindingItemCollapsed(item) {
|
||||
const code = item.code || item.findingId || "finding";
|
||||
const latestRunId = item.latestRunId || "-";
|
||||
const hasLatestRun = latestRunId !== "-";
|
||||
const codeLabel = displayFindingCode(code);
|
||||
return `<article class="finding-item is-collapsed" data-finding-run-id="${escapeAttr(latestRunId)}">
|
||||
<div class="finding-row" data-finding-toggle>
|
||||
<span class="finding-title">${escapeHtml(codeLabel)}${codeLabel === code ? "" : ` <small class="mono">${escapeHtml(code)}</small>`}<small> · ${formatNumber(item.count ?? 0)} 次</small></span>
|
||||
<span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span>
|
||||
</div>
|
||||
<div class="finding-detail">
|
||||
<div class="finding-summary">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 220))}</div>
|
||||
<div class="finding-actions">
|
||||
<button type="button" class="filter-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(code)}</button>
|
||||
<button type="button" class="filter-chip" data-finding-filter="scenario" data-filter-value="${escapeAttr(item.scenarioId || "")}">${escapeHtml(item.scenarioId || "-")}</button>
|
||||
</div>
|
||||
<div class="finding-meta">次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}</div>
|
||||
<div class="finding-meta mono">run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}</div>
|
||||
<div class="finding-meta">${escapeHtml(findingNextAction(code))}</div>
|
||||
<button type="button" class="link-button" data-open-finding-run="${escapeAttr(latestRunId)}"${hasLatestRun ? "" : " disabled"}>打开最近运行</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function bindFindingItemEvents(scope) {
|
||||
const container = scope || refs.findingsList;
|
||||
for (const row of container.querySelectorAll("[data-finding-toggle]")) {
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("[data-open-finding-run]") || event.target.closest("[data-finding-filter]")) return;
|
||||
const item = row.closest(".finding-item");
|
||||
if (item) item.classList.toggle("is-collapsed");
|
||||
});
|
||||
}
|
||||
for (const button of container.querySelectorAll("[data-open-finding-run]")) {
|
||||
button.addEventListener("click", () => selectRun(button.dataset.openFindingRun));
|
||||
}
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-finding-filter]")) {
|
||||
for (const button of container.querySelectorAll("[data-finding-filter]")) {
|
||||
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
}
|
||||
}
|
||||
@@ -423,28 +558,6 @@ function findingVisibleLimit(severity) {
|
||||
return responsiveLimit(key === "red" || key === "critical" || key === "error" ? dashboardLimits.findingsRed : dashboardLimits.findingsOther);
|
||||
}
|
||||
|
||||
function renderFindingItem(item) {
|
||||
const code = item.code || item.findingId || "finding";
|
||||
const latestRunId = item.latestRunId || "-";
|
||||
const hasLatestRun = latestRunId !== "-";
|
||||
const codeLabel = displayFindingCode(code);
|
||||
return `<article class="finding-item" data-finding-run-id="${escapeAttr(latestRunId)}">
|
||||
<div class="finding-row">
|
||||
<span class="finding-title">${escapeHtml(codeLabel)}${codeLabel === code ? "" : ` <small class="mono">${escapeHtml(code)}</small>`}</span>
|
||||
<span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span>
|
||||
</div>
|
||||
<div class="finding-summary">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}</div>
|
||||
<div class="finding-actions" aria-label="发现项过滤">
|
||||
<button type="button" class="filter-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(code)}</button>
|
||||
<button type="button" class="filter-chip" data-finding-filter="scenario" data-filter-value="${escapeAttr(item.scenarioId || "")}">${escapeHtml(item.scenarioId || "-")}</button>
|
||||
</div>
|
||||
<div class="finding-meta">次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}</div>
|
||||
<div class="finding-meta mono">run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}</div>
|
||||
<div class="finding-meta">${escapeHtml(findingNextAction(code))}</div>
|
||||
<button type="button" class="link-button" data-open-finding-run="${escapeAttr(latestRunId)}"${hasLatestRun ? "" : " disabled"}>打开最近运行</button>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function groupedFindingsBySeverity(findings) {
|
||||
const severityOrder = ["red", "critical", "error", "warning", "amber", "info", "unknown"];
|
||||
const groups = new Map();
|
||||
@@ -482,7 +595,7 @@ function renderFindingAggregation() {
|
||||
`<div class="aggregation-group"><span>窗口</span><button type="button" class="filter-chip active">${escapeHtml(state.findingFilters.window || "全部")}</button></div>`,
|
||||
].join("");
|
||||
for (const button of refs.findingAggregation.querySelectorAll("[data-finding-filter]")) {
|
||||
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
button.addEventListener("click", () => openFindingsDrilldown(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,22 +617,45 @@ function aggregateFindings(keyFn) {
|
||||
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function openFindingsDrilldown(filterKey, filterValue) {
|
||||
if (!filterValue || filterValue === "-") return;
|
||||
state.findingDrilldownKey = `${filterKey}:${filterValue}`;
|
||||
syncLocationQuery();
|
||||
renderFindings();
|
||||
}
|
||||
|
||||
function applyFindingFilter(key, value) {
|
||||
if (!value || value === "-") return;
|
||||
if (key === "severity") state.findingFilters.severity = value;
|
||||
if (key === "code") state.findingFilters.code = value;
|
||||
if (key === "scenario") state.findingFilters.scenario = value;
|
||||
writeFindingFiltersToControls();
|
||||
updateFindingsFilterSummary();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
if (!DETAIL_TABS.includes(tab)) return;
|
||||
state.selectedTab = tab;
|
||||
for (const button of refs.detailTabs.querySelectorAll("[data-detail-tab]")) {
|
||||
button.classList.toggle("active", button.dataset.detailTab === tab);
|
||||
}
|
||||
syncLocationQuery();
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!state.selectedRunId || !state.runDetail) {
|
||||
refs.detailSubtitle.textContent = "未选择运行";
|
||||
refs.detailTabs.hidden = true;
|
||||
refs.detailContent.innerHTML = '<div class="empty-state">请选择一条运行</div>';
|
||||
return;
|
||||
}
|
||||
const tab = state.selectedTab || "overview";
|
||||
for (const button of refs.detailTabs.querySelectorAll("[data-detail-tab]")) {
|
||||
button.classList.toggle("active", button.dataset.detailTab === tab);
|
||||
}
|
||||
const detail = state.runDetail;
|
||||
const run = detail.run || {};
|
||||
const findings = Array.isArray(detail.findings) ? detail.findings : [];
|
||||
@@ -527,7 +663,34 @@ function renderDetail() {
|
||||
const commands = detail.commands || {};
|
||||
const turnSummaryView = selectedView(state.runViews, "turn-summary");
|
||||
refs.detailSubtitle.textContent = run.runId || state.selectedRunId;
|
||||
refs.detailContent.innerHTML = [
|
||||
refs.detailContent.innerHTML = renderDetailTab(tab, detail, run, findings, artifacts, commands, turnSummaryView);
|
||||
bindDetailControls();
|
||||
}
|
||||
|
||||
function renderDetailTab(tab, detail, run, findings, artifacts, commands, turnSummaryView) {
|
||||
if (tab === "findings") {
|
||||
return detailFindings(findings);
|
||||
}
|
||||
if (tab === "turn") {
|
||||
return detailTurnSummary(turnSummaryView);
|
||||
}
|
||||
if (tab === "trace") {
|
||||
return detailTraceReader(state.runViews, commands);
|
||||
}
|
||||
if (tab === "evidence") {
|
||||
return [
|
||||
detailArtifacts(artifacts),
|
||||
detailCommands(commands),
|
||||
detailEvidence(detail, artifacts),
|
||||
detailBlock("脱敏", [
|
||||
["values", detail.valuesRedacted === true ? "已脱敏" : "-"],
|
||||
["prompt", detail.redaction?.prompt || "-"],
|
||||
["assistant", detail.redaction?.assistantFinal || "-"],
|
||||
]),
|
||||
].join("");
|
||||
}
|
||||
// overview
|
||||
return [
|
||||
detailBlock("追溯信息", [
|
||||
["run", run.runId || "-"],
|
||||
["observer", run.observerId || "-"],
|
||||
@@ -542,44 +705,29 @@ function renderDetail() {
|
||||
["updated", run.updatedAt || "-"],
|
||||
["views", Array.isArray(detail.viewsAvailable) ? detail.viewsAvailable.join(", ") : "-"],
|
||||
]),
|
||||
detailBlock("报告摘要", safeSummaryRows(detail.summary), "detail-block-wide"),
|
||||
detailFindings(findings),
|
||||
detailArtifacts(artifacts),
|
||||
detailCommands(commands),
|
||||
detailTurnSummary(turnSummaryView),
|
||||
detailTraceReader(state.runViews, commands),
|
||||
detailEvidence(detail, artifacts),
|
||||
detailBlock("脱敏", [
|
||||
["values", detail.valuesRedacted === true ? "已脱敏" : "-"],
|
||||
["prompt", detail.redaction?.prompt || "-"],
|
||||
["assistant", detail.redaction?.assistantFinal || "-"],
|
||||
]),
|
||||
detailBlock("报告摘要", safeSummaryRows(detail.summary)),
|
||||
].join("");
|
||||
bindDetailControls();
|
||||
}
|
||||
|
||||
function detailBlock(title, rows, className = "") {
|
||||
const visibleRows = rows.length > 0 ? rows : [["-", "-"]];
|
||||
return `<article class="detail-block ${escapeAttr(className)}"><strong>${escapeHtml(title)}</strong><div class="detail-grid">${
|
||||
visibleRows.map(([key, value]) => `<div><span>${escapeHtml(key)}:</span> <span class="mono">${escapeHtml(shortText(value, 180))}</span></div>`).join("")
|
||||
}</div></article>`;
|
||||
function detailBlock(title, rows, extraClass) {
|
||||
const body = rows.map(([key, value]) => `<div><span>${escapeHtml(key)}</span><code>${escapeHtml(String(value ?? "-"))}</code></div>`).join("");
|
||||
return `<article class="detail-block${extraClass ? ` ${extraClass}` : ""}"><strong>${escapeHtml(title)}</strong><div class="detail-grid">${body}</div></article>`;
|
||||
}
|
||||
|
||||
function detailFindings(findings) {
|
||||
if (findings.length === 0) return detailBlock("运行发现项", [["status", "无"]], "detail-block-wide");
|
||||
return `<article class="detail-block detail-block-wide"><strong>运行发现项</strong>
|
||||
<div class="detail-table-frame">
|
||||
<table class="detail-table">
|
||||
<thead><tr><th>严重级别</th><th>代码</th><th>次数</th><th>摘要</th><th>报告</th></tr></thead>
|
||||
<tbody>${findings.map((item) => `<tr>
|
||||
<td><span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span></td>
|
||||
<td class="mono">${escapeHtml(item.finding_id || item.findingId || "-")}</td>
|
||||
<td>${escapeHtml(String(item.count ?? 0))}</td>
|
||||
<td>${escapeHtml(shortText(displayFindingSummary(item.finding_id || item.findingId || "", item.summary || ""), 220))}</td>
|
||||
<td class="mono">${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}</td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
if (!Array.isArray(findings) || findings.length === 0) {
|
||||
return '<article class="detail-block"><strong>运行发现项</strong><div class="empty-state compact">暂无发现项</div></article>';
|
||||
}
|
||||
return `<article class="detail-block"><strong>运行发现项 · ${formatNumber(findings.length)} 条</strong>
|
||||
<div class="detail-table-frame"><table class="detail-table"><thead><tr>
|
||||
<th>严重级别</th><th>代码</th><th>次数</th><th>摘要</th><th>报告</th>
|
||||
</tr></thead><tbody>${findings.map((item) => `<tr>
|
||||
<td><span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span></td>
|
||||
<td class="mono">${escapeHtml(item.finding_id || item.findingId || "-")}</td>
|
||||
<td>${escapeHtml(String(item.count ?? 0))}</td>
|
||||
<td>${escapeHtml(shortText(displayFindingSummary(item.finding_id || item.findingId || "", item.summary || ""), 220))}</td>
|
||||
<td class="mono">${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}</td>
|
||||
</tr>`).join("")}</tbody></table></div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@@ -591,7 +739,7 @@ function detailArtifacts(artifacts) {
|
||||
["screenshotPath", screenshot.path || "-"],
|
||||
["screenshotSha256", screenshot.sha256 || "-"],
|
||||
["publicOrigin", artifacts.publicOrigin || "-"],
|
||||
], "detail-block-wide");
|
||||
]);
|
||||
}
|
||||
|
||||
function detailCommands(commands) {
|
||||
@@ -600,17 +748,22 @@ function detailCommands(commands) {
|
||||
["turn-summary", commands.turnSummary || "-"],
|
||||
["trace-frame", commands.traceFrame || "-"],
|
||||
];
|
||||
return `<article class="detail-block detail-block-wide"><strong>CLI 对照命令</strong>
|
||||
<div class="command-list">${rows.map(([label, command]) => `<div><span>${escapeHtml(label)}</span><code>${escapeHtml(command)}</code></div>`).join("")}</div>
|
||||
// D4: CLI 命令改可复制按钮
|
||||
return `<article class="detail-block"><strong>CLI 对照命令</strong>
|
||||
<div class="command-list">${rows.map(([label, command]) => `<div class="command-row">
|
||||
<span class="command-label">${escapeHtml(label)}</span>
|
||||
<code class="command-code">${escapeHtml(command)}</code>
|
||||
<button type="button" class="copy-button" data-copy-command="${escapeAttr(command)}">复制</button>
|
||||
</div>`).join("")}</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function detailTurnSummary(view) {
|
||||
if (!view) return detailBlock("多轮摘要 - 第一层", [["status", "未索引"]], "detail-block-wide");
|
||||
if (view.ok === false) return detailBlock("多轮摘要 - 第一层", [["status", view.error || "不可用"]], "detail-block-wide");
|
||||
if (!view) return detailBlock("多轮摘要 - 第一层", [["status", "未索引"]]);
|
||||
if (view.ok === false) return detailBlock("多轮摘要 - 第一层", [["status", view.error || "不可用"]]);
|
||||
const text = redactDisplayText(view.renderedText || "");
|
||||
const note = `${formatNumber(view.renderedTextBytes || text.length)} bytes${view.truncated ? " 已截断" : ""}`;
|
||||
return `<article class="detail-block detail-block-wide"><strong>多轮摘要 - 第一层</strong>
|
||||
return `<article class="detail-block"><strong>多轮摘要 - 第一层</strong>
|
||||
<div class="view-note">${escapeHtml(note)}</div>
|
||||
<pre class="detail-pre">${escapeHtml(text || "-")}</pre>
|
||||
</article>`;
|
||||
@@ -626,7 +779,7 @@ function detailTraceReader(response, commands) {
|
||||
const traceNote = traceView
|
||||
? `${formatNumber(traceView.renderedTextBytes || traceText.length)} bytes${traceView.truncated ? " 已截断" : ""}`
|
||||
: "trace-frame 未索引";
|
||||
return `<article class="detail-block detail-block-wide trace-reader"><strong>Trace Frame - 第二层</strong>
|
||||
return `<article class="detail-block trace-reader"><strong>Trace Frame - 第二层</strong>
|
||||
<div class="trace-reader-grid">
|
||||
<section class="trace-turn-picker" aria-label="turn、trace、sample 选择">
|
||||
<div class="view-note">选择 turn / trace / sample</div>
|
||||
@@ -692,7 +845,7 @@ function detailEvidence(detail, artifacts) {
|
||||
["observerId", traceability.observerId || "-"],
|
||||
["runId", traceability.runId || "-"],
|
||||
["reportJsonSha256", traceability.reportJsonSha256 || artifacts.reportJsonSha256 || "-"],
|
||||
], "detail-block-wide");
|
||||
]);
|
||||
}
|
||||
|
||||
function selectedView(response, viewName) {
|
||||
@@ -708,6 +861,38 @@ function bindDetailControls() {
|
||||
renderDetail();
|
||||
});
|
||||
}
|
||||
for (const button of refs.detailContent.querySelectorAll("[data-copy-command]")) {
|
||||
button.addEventListener("click", () => copyCommand(button));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCommand(button) {
|
||||
const command = button.dataset.copyCommand || "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
} catch {
|
||||
// 降级:用临时 textarea
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = command;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try { document.execCommand("copy"); } catch { /* ignore */ }
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
button.classList.add("copied");
|
||||
button.textContent = "已复制";
|
||||
showCopyToast();
|
||||
window.setTimeout(() => {
|
||||
button.classList.remove("copied");
|
||||
button.textContent = "复制";
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function showCopyToast() {
|
||||
refs.copyToast.hidden = false;
|
||||
window.setTimeout(() => { refs.copyToast.hidden = true; }, 1200);
|
||||
}
|
||||
|
||||
function renderLoading(show) {
|
||||
@@ -721,6 +906,110 @@ function renderCheckChip(element, label, ok, detail) {
|
||||
element.className = `check-chip ${ok ? "check-ok" : "check-blocked"}`;
|
||||
}
|
||||
|
||||
function toggleChecks() {
|
||||
state.checksExpanded = !state.checksExpanded;
|
||||
refs.overviewChecks.classList.toggle("overview-checks-collapsed", !state.checksExpanded);
|
||||
}
|
||||
|
||||
function toggleTimeline() {
|
||||
state.timelineCollapsed = !state.timelineCollapsed;
|
||||
const panel = refs.timeline.closest(".timeline-panel");
|
||||
panel.classList.toggle("collapsed", state.timelineCollapsed);
|
||||
refs.timelineToggle.textContent = state.timelineCollapsed ? "展开" : "折叠";
|
||||
refs.timelineToggle.setAttribute("aria-expanded", String(!state.timelineCollapsed));
|
||||
}
|
||||
|
||||
function toggleRunsFilter() {
|
||||
state.runsFilterOpen = !state.runsFilterOpen;
|
||||
refs.filterForm.hidden = !state.runsFilterOpen;
|
||||
refs.runsFilterSummary.setAttribute("aria-expanded", String(state.runsFilterOpen));
|
||||
updateRunsFilterSummary();
|
||||
}
|
||||
|
||||
function toggleFindingsFilter() {
|
||||
state.findingsFilterOpen = !state.findingsFilterOpen;
|
||||
refs.findingsFilterForm.hidden = !state.findingsFilterOpen;
|
||||
refs.findingsFilterSummary.setAttribute("aria-expanded", String(state.findingsFilterOpen));
|
||||
updateFindingsFilterSummary();
|
||||
}
|
||||
|
||||
function updateRunsFilterSummary() {
|
||||
const f = state.filters;
|
||||
const parts = [];
|
||||
if (f.status) parts.push(`状态=${displayStatus(f.status)}`);
|
||||
if (f.severity) parts.push(`严重=${displaySeverity(f.severity)}`);
|
||||
if (f.window) parts.push(`时间=${f.window}`);
|
||||
if (f.search) parts.push(`搜索="${shortText(f.search, 16)}"`);
|
||||
if (f.sort && f.sort !== "updated") parts.push(`排序=${f.sort}`);
|
||||
refs.runsFilterSummary.textContent = `筛选: ${parts.length ? parts.join(" · ") : "全部"}`;
|
||||
}
|
||||
|
||||
function updateFindingsFilterSummary() {
|
||||
const f = state.findingFilters;
|
||||
const parts = [];
|
||||
if (f.severity) parts.push(`严重=${displaySeverity(f.severity)}`);
|
||||
parts.push(`窗口=${f.window || "全部"}`);
|
||||
if (f.code) parts.push(`代码="${shortText(f.code, 16)}"`);
|
||||
if (f.scenario) parts.push(`场景="${shortText(f.scenario, 16)}"`);
|
||||
refs.findingsFilterSummary.textContent = `筛选: ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
function jumpToLatestRun() {
|
||||
const latest = state.runs[0];
|
||||
if (latest) selectRun(latest.runId || latest.id);
|
||||
}
|
||||
|
||||
function toggleRedOnly() {
|
||||
const active = refs.filterRed.classList.toggle("active");
|
||||
if (active) {
|
||||
state.filters.severity = "red";
|
||||
} else {
|
||||
state.filters.severity = "";
|
||||
}
|
||||
refs.filterSeverity.value = state.filters.severity;
|
||||
updateRunsFilterSummary();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
|
||||
function handleKeyboardShortcut(event) {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || event.target instanceof HTMLTextAreaElement) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "r") { event.preventDefault(); loadDashboard({ silent: false }); return; }
|
||||
if (key === "/") { event.preventDefault(); state.runsFilterOpen = true; refs.filterForm.hidden = false; refs.runsFilterSummary.setAttribute("aria-expanded", "true"); refs.filterSearch.focus(); return; }
|
||||
if (key === "j" || key === "k") { event.preventDefault(); navigateRun(key === "j" ? 1 : -1); return; }
|
||||
if (key === "enter") {
|
||||
event.preventDefault();
|
||||
if (state.selectedRunId) { selectTab("overview"); return; }
|
||||
const first = state.runs[0];
|
||||
if (first) selectRun(first.runId || first.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateRun(direction) {
|
||||
const ids = state.runs.map((run) => run.runId || run.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
const currentIndex = state.selectedRunId ? ids.indexOf(state.selectedRunId) : -1;
|
||||
let nextIndex = currentIndex + direction;
|
||||
if (nextIndex < 0) nextIndex = 0;
|
||||
if (nextIndex >= ids.length) nextIndex = ids.length - 1;
|
||||
const nextId = ids[nextIndex];
|
||||
if (nextId && nextId !== state.selectedRunId) selectRun(nextId);
|
||||
}
|
||||
|
||||
function updateViewportClass() {
|
||||
const width = window.innerWidth;
|
||||
const viewport = width >= 1024 ? "desktop" : width >= 981 ? "tablet" : "mobile";
|
||||
root.dataset.viewport = viewport;
|
||||
if (state.overview) {
|
||||
renderTimeline();
|
||||
renderRuns();
|
||||
renderFindings();
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilterControls() {
|
||||
state.filters = {
|
||||
status: refs.filterStatus.value,
|
||||
@@ -729,6 +1018,8 @@ function applyFilterControls() {
|
||||
search: refs.filterSearch.value.trim(),
|
||||
sort: refs.filterSort.value || "updated",
|
||||
};
|
||||
updateRunsFilterSummary();
|
||||
refs.filterRed.classList.toggle("active", state.filters.severity === "red");
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
@@ -740,6 +1031,7 @@ function applyFindingFilterControls() {
|
||||
code: refs.findingFilterCode.value.trim(),
|
||||
scenario: refs.findingFilterScenario.value.trim(),
|
||||
};
|
||||
updateFindingsFilterSummary();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
@@ -750,11 +1042,12 @@ function writeFiltersToControls() {
|
||||
refs.filterWindow.value = state.filters.window || "";
|
||||
refs.filterSearch.value = state.filters.search || "";
|
||||
refs.filterSort.value = state.filters.sort || "updated";
|
||||
refs.filterRed.classList.toggle("active", state.filters.severity === "red");
|
||||
}
|
||||
|
||||
function writeFindingFiltersToControls() {
|
||||
refs.findingFilterSeverity.value = state.findingFilters.severity || "";
|
||||
refs.findingFilterWindow.value = state.findingFilters.window || "";
|
||||
refs.findingFilterWindow.value = state.findingFilters.window || "24h";
|
||||
refs.findingFilterCode.value = state.findingFilters.code || "";
|
||||
refs.findingFilterScenario.value = state.findingFilters.scenario || "";
|
||||
}
|
||||
@@ -780,6 +1073,11 @@ function readFindingFiltersFromLocation() {
|
||||
};
|
||||
}
|
||||
|
||||
function readTabFromLocation() {
|
||||
const tab = new URLSearchParams(window.location.search).get("tab");
|
||||
return DETAIL_TABS.includes(tab) ? tab : "overview";
|
||||
}
|
||||
|
||||
function syncLocationQuery() {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(state.filters)) {
|
||||
@@ -795,6 +1093,9 @@ function syncLocationQuery() {
|
||||
if (value && !(key === "window" && value === "24h")) query.set(findingQueryKeys[key], value);
|
||||
}
|
||||
if (state.selectedRunId) query.set("run", state.selectedRunId);
|
||||
// C3: URL 支持 tab deep-link
|
||||
if (state.selectedRunId && state.selectedTab && state.selectedTab !== "overview") query.set("tab", state.selectedTab);
|
||||
if (state.findingDrilldownKey) query.set("finding", state.findingDrilldownKey);
|
||||
const next = `${window.location.pathname}${query.toString() ? `?${query.toString()}` : ""}`;
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
@@ -1003,9 +1304,9 @@ function formatRelative(iso) {
|
||||
if (seconds < 5) return "刚刚";
|
||||
if (seconds < 60) return `${seconds} 秒前`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
if (minutes < 60) return `${minutes} 分前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (hours < 24) return `${hours} 时前`;
|
||||
return `${Math.floor(hours / 24)} 天前`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
|
||||
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
@@ -11,7 +11,7 @@ interface DashboardShellConfig {
|
||||
}
|
||||
|
||||
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
|
||||
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p8-web-probe-sentinel-recovery";
|
||||
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
|
||||
|
||||
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
|
||||
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
|
||||
@@ -32,126 +32,105 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
|
||||
data-public-origin="${escapeAttr(publicOrigin)}"
|
||||
data-config-ready="${config.plan.ok ? "true" : "false"}"
|
||||
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
|
||||
data-viewport=""
|
||||
>
|
||||
<section class="sentinel-topbar" aria-label="Web哨兵概览">
|
||||
<div class="sentinel-title">
|
||||
<div class="sentinel-mark" aria-hidden="true"></div>
|
||||
<div>
|
||||
<h1>HWLAB Web哨兵</h1>
|
||||
<p id="sentinel-subtitle">${escapeHtml(config.node)} / ${escapeHtml(config.lane)}</p>
|
||||
<p id="sentinel-subtitle">${escapeHtml(config.node)} / ${escapeHtml(config.lane)}<span id="sentinel-origin-note"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sentinel-toolbar" aria-label="监控面板控制">
|
||||
<span id="status-pill" class="status-pill status-idle">空闲</span>
|
||||
<label class="refresh-toggle">
|
||||
<input id="auto-refresh-enabled" type="checkbox">
|
||||
<span>自动刷新</span>
|
||||
<span>自动</span>
|
||||
</label>
|
||||
<select id="auto-refresh-interval" aria-label="自动刷新间隔">
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
</select>
|
||||
<button id="manual-refresh" class="icon-button" type="button" title="刷新" aria-label="刷新">
|
||||
<span aria-hidden="true">刷新</span>
|
||||
</button>
|
||||
<button id="manual-refresh" class="icon-button" type="button" title="刷新 (r)" aria-label="刷新">刷新</button>
|
||||
<button id="latest-run" class="icon-button" type="button" title="跳转最新运行" aria-label="跳转最新">最新</button>
|
||||
<button id="filter-red" class="icon-button" type="button" title="只看红色发现" aria-label="只看红色">红</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="loading-banner" class="banner banner-muted">加载中</section>
|
||||
<section id="status-summary" class="status-summary" aria-label="状态摘要条" hidden>
|
||||
<span class="summary-item"><span class="summary-label">状态</span><strong id="summary-status">-</strong></span>
|
||||
<span class="summary-item"><span class="summary-label">最近运行</span><strong id="summary-latest">-</strong><small id="summary-latest-age"></small></span>
|
||||
<span class="summary-item"><span class="summary-label">发现</span><strong id="summary-findings">0</strong><small id="summary-findings-note"></small></span>
|
||||
<span class="summary-item"><span class="summary-label">调度器</span><strong id="summary-scheduler">-</strong><small id="summary-budget"></small></span>
|
||||
<span class="summary-item summary-checks" id="summary-checks" hidden></span>
|
||||
</section>
|
||||
|
||||
<section id="loading-banner" class="banner banner-muted" hidden>加载中</section>
|
||||
<section id="error-banner" class="banner banner-danger" hidden></section>
|
||||
|
||||
<section class="metric-grid" aria-label="哨兵状态指标">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">当前状态</span>
|
||||
<strong id="metric-overall">-</strong>
|
||||
<small id="metric-origin">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">最近运行</span>
|
||||
<strong id="metric-latest-run">-</strong>
|
||||
<small id="metric-latest-age">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">历史发现项</span>
|
||||
<strong id="metric-findings">0</strong>
|
||||
<small id="metric-findings-note">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">调度器</span>
|
||||
<strong id="metric-scheduler">-</strong>
|
||||
<small id="metric-budget">-</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="overview-checks" aria-label="哨兵健康检查">
|
||||
<span id="check-config" class="check-chip">config -</span>
|
||||
<span id="check-pvc" class="check-chip">pvc -</span>
|
||||
<span id="check-analyzer" class="check-chip">analyzer -</span>
|
||||
<span id="check-public" class="check-chip">public -</span>
|
||||
<span id="check-maintenance" class="check-chip">maintenance -</span>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel" aria-labelledby="timeline-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="timeline-heading">运行时间线</h2>
|
||||
<span id="timeline-count" class="panel-subtitle">-</span>
|
||||
<section class="overview-checks overview-checks-collapsed" id="overview-checks" aria-label="哨兵健康检查" hidden>
|
||||
<button type="button" class="check-summary-pill" id="check-summary-pill">检查 -</button>
|
||||
<div class="overview-checks-detail" id="overview-checks-detail">
|
||||
<span id="check-config" class="check-chip">config -</span>
|
||||
<span id="check-pvc" class="check-chip">pvc -</span>
|
||||
<span id="check-analyzer" class="check-chip">analyzer -</span>
|
||||
<span id="check-public" class="check-chip">public -</span>
|
||||
<span id="check-maintenance" class="check-chip">maintenance -</span>
|
||||
</div>
|
||||
<div id="run-timeline" class="run-timeline"></div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<section class="panel panel-wide" aria-labelledby="runs-heading">
|
||||
<section class="panel panel-runs" aria-labelledby="runs-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="runs-heading">运行历史</h2>
|
||||
<span id="runs-count" class="panel-subtitle">-</span>
|
||||
</div>
|
||||
<form id="runs-filter" class="runs-filter">
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select id="filter-status" name="status">
|
||||
<option value="">全部</option>
|
||||
<option value="planned">已计划</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="analyzed">已分析</option>
|
||||
<option value="blocked">阻塞</option>
|
||||
<option value="interrupted">已中断</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>严重级别</span>
|
||||
<select id="filter-severity" name="severity">
|
||||
<option value="">全部</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">信息</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>时间</span>
|
||||
<select id="filter-window" name="window">
|
||||
<option value="">全部</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="6h">6h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="7d">7d</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>排序</span>
|
||||
<select id="filter-sort" name="sort">
|
||||
<option value="updated">按更新时间</option>
|
||||
<option value="created">按创建时间</option>
|
||||
<option value="findings">按发现数量</option>
|
||||
<option value="severity">按严重级别</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-search">
|
||||
<span>搜索</span>
|
||||
<input id="filter-search" name="search" type="search" placeholder="run、observer、report">
|
||||
</label>
|
||||
<button id="clear-filters" class="icon-button" type="button">清除</button>
|
||||
</form>
|
||||
<div class="filter-collapse">
|
||||
<button type="button" class="filter-summary" id="runs-filter-summary" aria-expanded="false">筛选: 全部</button>
|
||||
<form id="runs-filter" class="runs-filter" hidden>
|
||||
<label><span>状态</span>
|
||||
<select id="filter-status" name="status">
|
||||
<option value="">全部</option>
|
||||
<option value="planned">已计划</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="analyzed">已分析</option>
|
||||
<option value="blocked">阻塞</option>
|
||||
<option value="interrupted">已中断</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>严重级别</span>
|
||||
<select id="filter-severity" name="severity">
|
||||
<option value="">全部</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">信息</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>时间</span>
|
||||
<select id="filter-window" name="window">
|
||||
<option value="">全部</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="6h">6h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="7d">7d</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>排序</span>
|
||||
<select id="filter-sort" name="sort">
|
||||
<option value="updated">按更新时间</option>
|
||||
<option value="created">按创建时间</option>
|
||||
<option value="findings">按发现数量</option>
|
||||
<option value="severity">按严重级别</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-search"><span>搜索</span>
|
||||
<input id="filter-search" name="search" type="search" placeholder="run、observer、report">
|
||||
</label>
|
||||
<button id="clear-filters" class="icon-button" type="button">清除</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-frame">
|
||||
<table class="runs-table">
|
||||
<thead>
|
||||
@@ -159,8 +138,8 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
|
||||
<th>运行</th>
|
||||
<th>状态</th>
|
||||
<th>场景</th>
|
||||
<th>发现项</th>
|
||||
<th>更新时间</th>
|
||||
<th>发现</th>
|
||||
<th>更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runs-body"></tbody>
|
||||
@@ -168,53 +147,71 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="findings-heading">
|
||||
<section class="panel panel-detail" aria-labelledby="detail-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="detail-heading">运行详情</h2>
|
||||
<span id="detail-subtitle" class="panel-subtitle">未选择运行</span>
|
||||
</div>
|
||||
<nav class="detail-tabs" id="detail-tabs" aria-label="详情分页" hidden>
|
||||
<button type="button" class="detail-tab active" data-detail-tab="overview">概要</button>
|
||||
<button type="button" class="detail-tab" data-detail-tab="findings">发现项</button>
|
||||
<button type="button" class="detail-tab" data-detail-tab="turn">多轮摘要</button>
|
||||
<button type="button" class="detail-tab" data-detail-tab="trace">Trace</button>
|
||||
<button type="button" class="detail-tab" data-detail-tab="evidence">证据与命令</button>
|
||||
</nav>
|
||||
<div id="detail-content" class="detail-content"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-findings" aria-labelledby="findings-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="findings-heading">发现分析</h2>
|
||||
<span id="findings-count" class="panel-subtitle">-</span>
|
||||
</div>
|
||||
<form id="findings-filter" class="findings-filter">
|
||||
<label>
|
||||
<span>严重级别</span>
|
||||
<select id="finding-filter-severity" name="fseverity">
|
||||
<option value="">全部</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">信息</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>窗口</span>
|
||||
<select id="finding-filter-window" name="fwindow">
|
||||
<option value="24h">24h</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="6h">6h</option>
|
||||
<option value="7d">7d</option>
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>代码</span>
|
||||
<input id="finding-filter-code" name="fcode" type="search" placeholder="finding code">
|
||||
</label>
|
||||
<label>
|
||||
<span>场景</span>
|
||||
<input id="finding-filter-scenario" name="fscenario" type="search" placeholder="scenario">
|
||||
</label>
|
||||
<button id="finding-clear-filters" class="icon-button" type="button">清除</button>
|
||||
</form>
|
||||
<div class="filter-collapse">
|
||||
<button type="button" class="filter-summary" id="findings-filter-summary" aria-expanded="false">筛选: 全部</button>
|
||||
<form id="findings-filter" class="findings-filter" hidden>
|
||||
<label><span>严重级别</span>
|
||||
<select id="finding-filter-severity" name="fseverity">
|
||||
<option value="">全部</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">信息</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>窗口</span>
|
||||
<select id="finding-filter-window" name="fwindow">
|
||||
<option value="24h">24h</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="6h">6h</option>
|
||||
<option value="7d">7d</option>
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>代码</span>
|
||||
<input id="finding-filter-code" name="fcode" type="search" placeholder="finding code">
|
||||
</label>
|
||||
<label><span>场景</span>
|
||||
<input id="finding-filter-scenario" name="fscenario" type="search" placeholder="scenario">
|
||||
</label>
|
||||
<button id="finding-clear-filters" class="icon-button" type="button">清除</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="finding-aggregation" class="finding-aggregation"></div>
|
||||
<div id="findings-list" class="finding-list"></div>
|
||||
<div id="findings-drilldown" class="findings-drilldown" hidden></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel detail-panel" aria-labelledby="detail-heading" role="dialog" aria-modal="false">
|
||||
<section class="panel timeline-panel" aria-labelledby="timeline-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="detail-heading">运行详情</h2>
|
||||
<span id="detail-subtitle" class="panel-subtitle">未选择运行</span>
|
||||
<h2 id="timeline-heading">运行时间线</h2>
|
||||
<span id="timeline-count" class="panel-subtitle">-</span>
|
||||
<button type="button" class="timeline-toggle" id="timeline-toggle" aria-expanded="true">折叠</button>
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content"></div>
|
||||
<div id="run-timeline" class="run-timeline"></div>
|
||||
</section>
|
||||
|
||||
<div id="copy-toast" class="copy-toast" hidden>已复制</div>
|
||||
</main>
|
||||
<script type="module" src="/dashboard/assets/dashboard.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
|
||||
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -12,7 +12,7 @@ import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResp
|
||||
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
|
||||
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||
|
||||
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p8-web-probe-sentinel-recovery";
|
||||
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
|
||||
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
|
||||
|
||||
export interface WebProbeSentinelServiceConfig {
|
||||
|
||||
Reference in New Issue
Block a user