// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // 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 * @typedef {{id?: string|null, runId?: string|null, scenarioId?: string|null, status?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, findingCount?: number, artifactCount?: number, updatedAt?: string|null, maxSeverity?: string|null, traceability?: Traceability}} SentinelRun * @typedef {{ok: boolean, status?: string, node?: string, lane?: string, publicOrigin?: string|null, latestRun?: SentinelRun|null, runCounts?: Record, severityCounts?: Record, freshness?: Record, targetValidation?: {maxSeconds?: number, scenarioId?: string|null}, valuesRedacted?: boolean}} SentinelOverview * @typedef {{code?: string|null, findingId?: string|null, severity?: string|null, scenarioId?: string|null, count?: number, runCount?: number, latestAt?: string|null, latestRunId?: string|null, latestReportJsonSha256?: string|null, summary?: string|null, traceability?: Traceability|null}} SentinelFindingGroup * @typedef {{groups?: SentinelFindingGroup[], findings?: SentinelFindingGroup[], page?: {limit?: number, hasMore?: boolean}}} SentinelFindingsResponse * @typedef {{items?: SentinelRun[], runs?: SentinelRun[], page?: {limit?: number, nextCursor?: string|null, hasMore?: boolean}}} SentinelRunsResponse * @typedef {{ok?: boolean, view?: string, renderedText?: string, renderedTextBytes?: number, truncated?: boolean, error?: string|null, finalResponse?: Record|null}} SentinelRunView * @typedef {{ok?: boolean, run?: SentinelRun, summary?: Record, findings?: Record[], artifacts?: Record, commands?: Record, redaction?: Record, traceability?: Traceability, viewsAvailable?: string[], valuesRedacted?: boolean}} SentinelRunDetail */ const root = document.getElementById("sentinel-dashboard"); const basePath = normalizeBasePath(root?.dataset?.basePath || ""); const refs = { statusPill: document.getElementById("status-pill"), loadingBanner: document.getElementById("loading-banner"), errorBanner: document.getElementById("error-banner"), manualRefresh: document.getElementById("manual-refresh"), autoRefreshEnabled: document.getElementById("auto-refresh-enabled"), autoRefreshInterval: document.getElementById("auto-refresh-interval"), 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"), checkPublic: document.getElementById("check-public"), 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"), filterWindow: document.getElementById("filter-window"), filterSort: document.getElementById("filter-sort"), filterSearch: document.getElementById("filter-search"), 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"), findingFilterCode: document.getElementById("finding-filter-code"), findingFilterScenario: document.getElementById("finding-filter-scenario"), findingClearFilters: document.getElementById("finding-clear-filters"), 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 DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"]; const state = { loading: false, selectedRunId: null, /** @type {SentinelOverview|null} */ overview: null, /** @type {SentinelRun[]} */ runs: [], /** @type {SentinelFindingGroup[]} */ findings: [], /** @type {SentinelRunDetail|null} */ runDetail: null, runViews: null, selectedTraceChoiceIndex: 0, 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 dashboardLimits = { 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 }, }; const dashboardApi = createDashboardApi(); const autoRefresh = createAutoRefresh({ storageKey: "hwlab.webProbeSentinel.dashboard.autoRefresh", intervals: [5, 10, 30], defaultInterval: 10, onRefresh: () => loadDashboard({ silent: true }), 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()); } refs.filterSearch.addEventListener("input", debounce(() => applyFilterControls(), 250)); refs.clearFilters.addEventListener("click", () => { state.filters = { status: "", severity: "", window: "", search: "", sort: "updated" }; writeFiltersToControls(); updateRunsFilterSummary(); syncLocationQuery(); loadDashboard({ silent: false }); }); refs.findingsFilterForm.addEventListener("submit", (event) => event.preventDefault()); for (const control of [refs.findingFilterSeverity, refs.findingFilterWindow]) { control.addEventListener("change", () => applyFindingFilterControls()); } for (const control of [refs.findingFilterCode, refs.findingFilterScenario]) { control.addEventListener("input", debounce(() => applyFindingFilterControls(), 250)); } 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 }); }); 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() { return { /** @returns {Promise} */ overview: () => getJson(apiPath("/api/overview")), /** @returns {Promise} */ runs: (filters) => getJson(apiPath(`/api/runs?${runsQuery(filters)}`)), /** @returns {Promise} */ findings: (filters) => getJson(apiPath(`/api/findings?${findingsQuery(filters)}`)), /** @returns {Promise} */ runDetail: (runId) => getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}`)), runViews: (runId, view = null) => { const query = new URLSearchParams({ maxBytes: "24000" }); if (view) query.set("view", view); return getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`)); }, }; } function apiPath(path) { return `${basePath}${path}`; } function normalizeBasePath(value) { const text = String(value || "").replace(/\/+$/u, ""); if (!text || text === "/") return ""; return text.startsWith("/") ? text : `/${text}`; } async function getJson(path) { const response = await fetch(path, { headers: { accept: "application/json" } }); const body = await response.json().catch(() => ({})); if (!response.ok || body.ok === false) { const message = body.error || `${response.status} ${response.statusText}`; throw new Error(message); } return body; } function createAutoRefresh(options) { let enabled = false; let intervalSeconds = options.defaultInterval; let countdown = intervalSeconds; let timer = null; try { const saved = JSON.parse(localStorage.getItem(options.storageKey) || "{}"); if (saved.enabled === true) enabled = true; if (options.intervals.includes(Number(saved.intervalSeconds))) intervalSeconds = Number(saved.intervalSeconds); } catch { enabled = false; } function persist() { try { localStorage.setItem(options.storageKey, JSON.stringify({ enabled, intervalSeconds })); } catch { /* ignore */ } } async function tick() { if (!enabled || options.shouldPause()) return; if (countdown > 0) { countdown -= 1; return; } countdown = intervalSeconds; await options.onRefresh(); } function start() { if (timer !== null) return; timer = window.setInterval(tick, 1000); } function stop() { if (timer === null) return; window.clearInterval(timer); timer = null; } return { enabled: () => enabled, intervalSeconds: () => intervalSeconds, setEnabled(value) { enabled = value; countdown = intervalSeconds; persist(); if (enabled) start(); else stop(); }, setInterval(value) { if (!options.intervals.includes(value)) return; intervalSeconds = value; countdown = value; persist(); }, start, }; } function hydrateControls() { refs.autoRefreshEnabled.checked = autoRefresh.enabled(); refs.autoRefreshInterval.value = String(autoRefresh.intervalSeconds()); if (autoRefresh.enabled()) autoRefresh.start(); } async function loadDashboard(options) { if (state.loading) return; state.loading = true; renderLoading(!options.silent); try { const [overview, runs, findings] = await Promise.all([ dashboardApi.overview(), dashboardApi.runs(state.filters), dashboardApi.findings(state.findingFilters), ]); state.overview = overview; state.runs = Array.isArray(runs.items) ? runs.items : Array.isArray(runs.runs) ? runs.runs : []; state.findings = Array.isArray(findings.groups) ? findings.groups : Array.isArray(findings.findings) ? findings.findings : []; state.lastUpdatedAt = new Date(); const queryRun = new URLSearchParams(window.location.search).get("run"); if (queryRun && state.selectedRunId === null) state.selectedRunId = queryRun; if (state.selectedRunId && !state.runs.some((run) => run.runId === state.selectedRunId || run.id === state.selectedRunId)) { state.selectedRunId = null; state.runDetail = null; state.runViews = null; state.selectedTraceChoiceIndex = 0; } if (state.selectedRunId === null) { const latestRunId = latestRunIdFrom(overview, state.runs); if (latestRunId) state.selectedRunId = latestRunId; } renderDashboard(); if (state.selectedRunId && state.runDetail === null) await selectRun(state.selectedRunId); } catch (error) { renderError(error); } finally { state.loading = false; renderLoading(false); } } async function selectRun(runId) { if (!runId) return; state.selectedRunId = runId; state.runDetail = null; state.runViews = null; state.selectedTraceChoiceIndex = 0; syncLocationQuery(); renderRuns(); refs.detailSubtitle.textContent = runId; refs.detailTabs.hidden = false; refs.detailContent.innerHTML = '
加载中
'; try { const [detail, views] = await Promise.all([ dashboardApi.runDetail(runId), loadRunViews(runId), ]); state.runDetail = detail; state.runViews = views; renderDetail(); } catch (error) { refs.detailContent.innerHTML = `
${escapeHtml(error.message || String(error))}
`; } } async function loadRunViews(runId, view = null) { try { return await dashboardApi.runViews(runId, view); } catch (error) { return { ok: false, error: error?.message || String(error), valuesRedacted: true }; } } function renderDashboard() { refs.errorBanner.hidden = true; renderOverview(); renderTimeline(); renderRuns(); renderFindings(); renderDetail(); } function renderOverview() { const overview = state.overview || {}; const status = overview.status || (overview.ok ? "healthy" : "degraded"); const latest = overview.latestRun || null; const latestStatus = latest?.status || status; refs.statusPill.textContent = displayStatus(latestStatus); refs.statusPill.className = `status-pill ${statusClass(latestStatus)}`; // 状态摘要条合并 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.summaryFindings.textContent = formatNumber(totalFindings); refs.summaryFindingsNote.textContent = formatSeveritySummary(severityCounts); const scheduler = overview.scheduler || {}; const heartbeat = scheduler.heartbeat || {}; refs.summaryScheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "未知"; const maxSeconds = overview.targetValidation?.maxSeconds ?? 120; 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 || "-"); 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)}`; if (state.runs.length === 0) { refs.timeline.innerHTML = '
暂无时间线
'; return; } refs.timeline.innerHTML = visibleRuns.map((run) => { const runId = run.runId || run.id || "-"; const age = run.updatedAt ? formatRelative(run.updatedAt) : "-"; const title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${age}`; return ``; }).join(""); for (const node of refs.timeline.querySelectorAll("[data-run-id]")) { node.addEventListener("click", () => selectRun(node.dataset.runId)); } } 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)} 条`; if (state.runs.length === 0) { refs.runsBody.innerHTML = '暂无运行'; return; } refs.runsBody.innerHTML = visibleRuns.map((run) => { const runId = run.runId || run.id || "-"; const selected = state.selectedRunId === runId ? " selected-row" : ""; const severityRowClass = run.maxSeverity ? ` ${severityClass(run.maxSeverity)}` : ""; return `
${escapeHtml(shortText(runId, 24))}${escapeHtml(run.observerId ? shortText(run.observerId, 28) : "-")}
${escapeHtml(displayStatus(run.status))} ${escapeHtml(run.scenarioId || "-")} ${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` ${escapeHtml(displaySeverity(run.maxSeverity))}` : ""} ${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}${run.maintenance ? `维护窗口` : ""} `; }).join("") + (hiddenCount > 0 ? `其余 ${formatNumber(hiddenCount)} 条通过筛选、搜索或 API drill-down 查看` : ""); for (const row of refs.runsBody.querySelectorAll("tr[data-run-id]")) { row.addEventListener("click", () => selectRun(row.dataset.runId)); } } 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 = '
暂无发现项
'; return; } // B8: 默认只显示聚合,list 折叠为 severity group summary(点击展开明细) const groups = groupedFindingsBySeverity(state.findings); 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); const defaultOpen = group.items.some(findingHasRootCause) || isBlockingSeverity(group.key) || groups.length <= 2; return `
${escapeHtml(displaySeverity(group.key))} ${formatNumber(group.totalCount)} ${formatNumber(group.items.length)} 组${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组` : ""}
${visibleItems.map((item) => renderFindingItemCollapsed(item)).join("")}
`; }).join(""); 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 = `
drill-down: ${escapeHtml(label)} · ${formatNumber(matched.length)} 组
${matched.map((item) => renderFindingItemCollapsed(item)).join("")}
`; 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); const rootCause = findingRootCauseText(item); const evidence = findingEvidenceText(item); const nextAction = item.nextAction || findingNextAction(code); const directCause = rootCause || nextAction; return ``; } 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 container.querySelectorAll("[data-finding-filter]")) { button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || "")); } } function responsiveLimit(limits) { if (window.matchMedia("(max-width: 560px)").matches) return limits.mobile; if (window.matchMedia("(max-width: 980px)").matches) return limits.tablet; return limits.desktop; } function findingVisibleLimit(severity) { const key = String(severity || "").toLowerCase(); return responsiveLimit(key === "red" || key === "critical" || key === "error" ? dashboardLimits.findingsRed : dashboardLimits.findingsOther); } function groupedFindingsBySeverity(findings) { const severityOrder = ["red", "critical", "error", "warning", "amber", "info", "unknown"]; const groups = new Map(); for (const item of findings) { const key = String(item.severity || "unknown").toLowerCase(); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(item); } return Array.from(groups.entries()) .map(([key, items]) => ({ key, items: items.sort(compareFindingsForVisibility), totalCount: items.reduce((sum, item) => sum + Number(item.count || 0), 0), })) .sort((a, b) => severityRank(a.key, severityOrder) - severityRank(b.key, severityOrder)); } function compareFindingsForVisibility(a, b) { return Number(findingHasRootCause(b)) - Number(findingHasRootCause(a)) || Number(b.count || 0) - Number(a.count || 0) || String(a.code || a.findingId || "").localeCompare(String(b.code || b.findingId || "")); } function findingHasRootCause(item) { return Boolean(findingRootCauseText(item)); } function isBlockingSeverity(severity) { const key = String(severity || "").toLowerCase(); return key === "red" || key === "critical" || key === "error"; } function severityRank(key, order) { const index = order.indexOf(key); return index >= 0 ? index : order.length; } function renderFindingAggregation() { if (state.findings.length === 0) { refs.findingAggregation.innerHTML = '
暂无发现聚合
'; return; } const severityEntries = aggregateFindings((item) => item.severity || "unknown").slice(0, 5); const codeEntries = aggregateFindings((item) => item.code || item.findingId || "unknown").slice(0, 5); const scenarioEntries = aggregateFindings((item) => item.scenarioId || "unknown").slice(0, 5); const rootCauseEntries = state.findings.filter(findingHasRootCause).sort(compareFindingsForVisibility).slice(0, 3); refs.findingAggregation.innerHTML = [ aggregationGroup("严重级别", severityEntries.map((item) => ({ ...item, label: displaySeverity(item.key) })), "severity"), aggregationGroup("代码", codeEntries.map((item) => ({ ...item, label: displayFindingCode(item.key) })), "code"), rootCauseEntries.length > 0 ? aggregationRootCauseGroup(rootCauseEntries) : "", aggregationGroup("场景", scenarioEntries, "scenario"), `
窗口
`, ].join(""); for (const button of refs.findingAggregation.querySelectorAll("[data-finding-filter]")) { button.addEventListener("click", () => openFindingsDrilldown(button.dataset.findingFilter, button.dataset.filterValue || "")); } } function aggregationGroup(label, entries, filterKey) { const chips = entries.length === 0 ? '-' : entries.map((entry) => ``).join(""); return `
${escapeHtml(label)}${chips}
`; } function aggregationRootCauseGroup(entries) { const chips = entries.map((item) => { const code = item.code || item.findingId || "finding"; const text = findingRootCauseText(item); return ``; }).join(""); return `
根因${chips}
`; } function aggregateFindings(keyFn) { const counts = new Map(); for (const item of state.findings) { const key = keyFn(item); counts.set(key, (counts.get(key) || 0) + Number(item.count || 0)); } return Array.from(counts.entries()) .map(([key, count]) => ({ key, count })) .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 = '
请选择一条运行
'; 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 : []; const artifacts = detail.artifacts || {}; const commands = detail.commands || {}; const turnSummaryView = selectedView(state.runViews, "turn-summary"); refs.detailSubtitle.textContent = run.runId || state.selectedRunId; 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 || "-"], ["stateDir", run.stateDir || "-"], ["report", run.reportJsonSha256 || "-"], ]), detailBlock("摘要", [ ["status", displayStatus(run.status)], ["scenario", run.scenarioId || "-"], ["findings", String(run.findingCount ?? 0)], ["artifacts", String(run.artifactCount ?? 0)], ["updated", run.updatedAt || "-"], ["views", Array.isArray(detail.viewsAvailable) ? detail.viewsAvailable.join(", ") : "-"], ]), detailBlock("报告摘要", safeSummaryRows(detail.summary)), ].join(""); } function detailBlock(title, rows, extraClass) { const body = rows.map(([key, value]) => `
${escapeHtml(key)}${escapeHtml(String(value ?? "-"))}
`).join(""); return `
${escapeHtml(title)}
${body}
`; } function detailFindings(findings) { if (!Array.isArray(findings) || findings.length === 0) { return '
运行发现项
暂无发现项
'; } return `
运行发现项 · ${formatNumber(findings.length)} 条
${findings.map((item) => ``).join("")}
严重级别代码次数摘要根因报告
${escapeHtml(displaySeverity(item.severity))} ${escapeHtml(item.finding_id || item.findingId || "-")} ${escapeHtml(String(item.count ?? 0))} ${escapeHtml(shortText(displayFindingSummary(item.finding_id || item.findingId || "", item.summary || ""), 220))} ${escapeHtml(shortText(findingRootCauseText(item) || item.nextAction || "-", 240))} ${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}
`; } function detailArtifacts(artifacts) { const screenshot = artifacts.screenshot || {}; return detailBlock("产物", [ ["artifactCount", String(artifacts.artifactCount ?? "-")], ["reportJsonSha256", artifacts.reportJsonSha256 || "-"], ["screenshotPath", screenshot.path || "-"], ["screenshotSha256", screenshot.sha256 || "-"], ["publicOrigin", artifacts.publicOrigin || "-"], ]); } function detailCommands(commands) { const rows = [ ["summary", commands.summary || "-"], ["turn-summary", commands.turnSummary || "-"], ["trace-frame", commands.traceFrame || "-"], ]; // D4: CLI 命令改可复制按钮 return `
CLI 对照命令
${rows.map(([label, command]) => `
${escapeHtml(label)} ${escapeHtml(command)}
`).join("")}
`; } function detailTurnSummary(view) { 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 `
多轮摘要 - 第一层
${escapeHtml(note)}
${escapeHtml(text || "-")}
`; } function detailTraceReader(response, commands) { const turnView = selectedView(response, "turn-summary"); const traceView = selectedView(response, "trace-frame"); const choices = traceChoices(turnView?.renderedText || "", traceView?.renderedText || ""); const selectedIndex = Math.min(state.selectedTraceChoiceIndex, Math.max(0, choices.length - 1)); const selected = choices[selectedIndex] || { label: "已保存 trace-frame", meta: "当前运行", key: "stored" }; const traceText = traceView?.ok === false ? "" : redactDisplayText(traceView?.renderedText || ""); const traceNote = traceView ? `${formatNumber(traceView.renderedTextBytes || traceText.length)} bytes${traceView.truncated ? " 已截断" : ""}` : "trace-frame 未索引"; return `
Trace Frame - 第二层
选择 turn / trace / sample
${choices.map((choice, index) => ``).join("")}
已选择: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}
${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame 不可用" : "trace-frame 未索引"))}
${finalResponseBlock(traceView)}
source=${escapeHtml(commands.traceFrame || "-")} · analyzer finding 不改写此文字证据
`; } function finalResponseBlock(traceView) { if (!traceView) { return `
Final Response
trace-frame 未索引
`; } if (traceView.ok === false) { return `
Final Response
${escapeHtml(traceView.error || "trace-frame 不可用")}
`; } const block = traceView.finalResponse || {}; const text = block.empty === true ? "(空内容)" : redactDisplayText(block.text || ""); const bytes = Number(block.byteCount || 0); return `
Final Response
${block.empty === true ? "空内容" : "有内容"} · ${formatNumber(bytes)} bytes · 已脱敏
${escapeHtml(text || "(空内容)")}
`; } function traceChoices(turnSummaryText, traceFrameText) { const sourceLines = String(turnSummaryText || "") .split("\n") .map((line) => line.trim()) .filter((line) => /(turn|trace|sample|final response|轮次|用户消息)/iu.test(line)) .slice(0, 12); const lines = sourceLines.length > 0 ? sourceLines : String(traceFrameText || "") .split("\n") .map((line) => line.trim()) .filter((line) => /(trace|sample|total=|final response)/iu.test(line)) .slice(0, 12); if (lines.length === 0) return [{ label: "已保存 trace-frame", meta: "当前运行", key: "stored" }]; return lines.map((line, index) => { const trace = line.match(/trace(?:Id)?[=: ]+([A-Za-z0-9_.:-]+)/u)?.[1] || line.match(/\btrc_[A-Za-z0-9_.:-]+/u)?.[0] || null; const sample = line.match(/sample(?:Seq)?[=: ]+([0-9]+)/u)?.[1] || null; const turn = line.match(/turn[=: #]+([0-9A-Za-z_.:-]+)/iu)?.[1] || null; const meta = [turn ? `turn ${turn}` : null, trace ? `trace ${trace}` : null, sample ? `sample ${sample}` : null].filter(Boolean).join(" · ") || `第 ${index + 1} 行`; return { label: shortText(redactDisplayText(line), 120), meta, key: `${trace || "line"}-${sample || index}` }; }); } function detailEvidence(detail, artifacts) { const traceability = detail.traceability || {}; return detailBlock("证据", [ ["source", traceability.source || "-"], ["stateRoot", traceability.stateRoot || "-"], ["stateDir", traceability.stateDir || "-"], ["observerId", traceability.observerId || "-"], ["runId", traceability.runId || "-"], ["reportJsonSha256", traceability.reportJsonSha256 || artifacts.reportJsonSha256 || "-"], ]); } function selectedView(response, viewName) { if (response?.ok === false) return { ok: false, error: response.error || "不可用", view: viewName }; const views = Array.isArray(response?.views) ? response.views : []; return views.find((item) => item.view === viewName) || null; } function bindDetailControls() { for (const button of refs.detailContent.querySelectorAll("[data-trace-choice-index]")) { button.addEventListener("click", () => { state.selectedTraceChoiceIndex = Number(button.dataset.traceChoiceIndex || 0); 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) { refs.loadingBanner.hidden = !show; refs.manualRefresh.disabled = state.loading; } function renderCheckChip(element, label, ok, detail) { const status = ok ? "正常" : "阻塞"; element.textContent = `${label} ${status}${detail ? ` · ${shortText(detail, 34)}` : ""}`; 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 latestRunIdFrom(overview, runs) { const latest = overview?.latestRun || null; return latest?.runId || latest?.id || runs[0]?.runId || runs[0]?.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, severity: refs.filterSeverity.value, window: refs.filterWindow.value, search: refs.filterSearch.value.trim(), sort: refs.filterSort.value || "updated", }; updateRunsFilterSummary(); refs.filterRed.classList.toggle("active", state.filters.severity === "red"); syncLocationQuery(); loadDashboard({ silent: false }); } function applyFindingFilterControls() { state.findingFilters = { severity: refs.findingFilterSeverity.value, window: refs.findingFilterWindow.value, code: refs.findingFilterCode.value.trim(), scenario: refs.findingFilterScenario.value.trim(), }; updateFindingsFilterSummary(); syncLocationQuery(); loadDashboard({ silent: false }); } function writeFiltersToControls() { refs.filterStatus.value = state.filters.status || ""; refs.filterSeverity.value = state.filters.severity || ""; 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 || "24h"; refs.findingFilterCode.value = state.findingFilters.code || ""; refs.findingFilterScenario.value = state.findingFilters.scenario || ""; } function readFiltersFromLocation() { const query = new URLSearchParams(window.location.search); return { status: query.get("status") || "", severity: query.get("severity") || "", window: query.get("window") || "", search: query.get("search") || "", sort: query.get("sort") || "updated", }; } function readFindingFiltersFromLocation() { const query = new URLSearchParams(window.location.search); return { severity: query.get("fseverity") || "", window: query.get("fwindow") || "24h", code: query.get("fcode") || "", scenario: query.get("fscenario") || "", }; } 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)) { if (value && !(key === "sort" && value === "updated")) query.set(key, value); } const findingQueryKeys = { severity: "fseverity", window: "fwindow", code: "fcode", scenario: "fscenario", }; for (const [key, value] of Object.entries(state.findingFilters)) { 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); } function runsQuery(filters) { const query = new URLSearchParams({ limit: "40", sort: filters.sort || "updated", direction: "desc" }); if (filters.status) query.set("status", filters.status); if (filters.severity) query.set("severity", filters.severity); if (filters.search) query.set("search", filters.search); const from = windowFrom(filters.window); if (from) query.set("from", from); return query.toString(); } function findingsQuery(filters) { const query = new URLSearchParams({ limit: "30" }); if (filters.severity) query.set("severity", filters.severity); if (filters.window) query.set("window", filters.window); if (filters.code) query.set("code", filters.code); if (filters.scenario) query.set("scenario", filters.scenario); return query.toString(); } function windowFrom(value) { if (!value) return ""; const match = /^(\d+)(h|d)$/u.exec(value); if (!match) return ""; const amount = Number(match[1]); const ms = match[2] === "h" ? amount * 3_600_000 : amount * 86_400_000; return new Date(Date.now() - ms).toISOString(); } function debounce(fn, waitMs) { let timer = null; return (...args) => { if (timer !== null) window.clearTimeout(timer); timer = window.setTimeout(() => fn(...args), waitMs); }; } function safeSummaryRows(summary) { const source = summary && typeof summary === "object" ? summary : {}; const rows = []; for (const [key, value] of Object.entries(source)) { if (!safeDisplayKey(key)) continue; if (rows.length >= 8) break; rows.push([key, safeDisplayValue(value)]); } return rows.length === 0 ? [["status", "未索引"]] : rows; } function safeDisplayKey(key) { return !/(prompt|provider|cookie|token|authorization|payload|stdout|stderr|assistant|secret|password)/iu.test(key); } function safeDisplayValue(value) { if (value === null || typeof value === "undefined") return "-"; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) return `${value.length} 项`; const entries = Object.entries(value).filter(([key]) => safeDisplayKey(key)).slice(0, 4); if (entries.length === 0) return "已脱敏对象"; return entries.map(([key, item]) => `${key}=${primitiveValue(item)}`).join(", "); } function primitiveValue(value) { if (value === null || typeof value === "undefined") return "-"; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return shortText(String(value), 80); if (Array.isArray(value)) return `${value.length} 项`; return "对象"; } function redactDisplayText(value) { return String(value || "") .split("\n") .map((line) => { if (/(prompt|cookie|token|authorization|provider|payload|api[_-]?key|password|secret|stdout|stderr)/iu.test(line)) return "[已脱敏]"; if (/assistant/iu.test(line) && line.length > 180) return "[assistant 正文已脱敏]"; return shortText(line, 360); }) .join("\n"); } function renderError(error) { refs.errorBanner.hidden = false; refs.errorBanner.textContent = error?.message || String(error); } function statusClass(value) { const normalized = String(value || "idle").toLowerCase(); if (["healthy", "observed", "analyzed", "pass", "ok"].includes(normalized)) return "status-healthy"; if (["warning", "degraded", "planned", "running", "queued"].includes(normalized)) return "status-warning"; if (["blocked", "failed", "error", "timeout", "interrupted"].includes(normalized)) return "status-blocked"; return "status-idle"; } function severityClass(value) { const normalized = String(value || "unknown").toLowerCase(); if (["red", "critical", "error"].includes(normalized)) return `severity-${normalized}`; if (["warning", "amber"].includes(normalized)) return `severity-${normalized}`; if (["info"].includes(normalized)) return "severity-info"; return "severity-unknown"; } function displayStatus(value) { const normalized = String(value || "idle").toLowerCase(); const labels = { healthy: "健康", observed: "已观察", analyzed: "已分析", pass: "通过", ok: "正常", warning: "警告", degraded: "降级", planned: "已计划", running: "运行中", queued: "排队中", blocked: "阻塞", failed: "失败", error: "错误", timeout: "超时", interrupted: "已中断", idle: "空闲", }; return labels[normalized] || String(value || "-"); } function displaySeverity(value) { const normalized = String(value || "unknown").toLowerCase(); const labels = { red: "红色", critical: "严重", error: "错误", warning: "警告", amber: "警告", info: "信息", unknown: "未知", }; return labels[normalized] || String(value || "-"); } function displayFindingCode(code) { const normalized = String(code || "").toLowerCase(); const labels = { "quick-verify-no-business-turn": "quick verify 未触达业务 turn", "session-rail-title-fallback-root-cause": "INV-02 会话标题 fallback 根因", "trace-events-page-read-404-root-cause": "INV-07 trace events 404 根因", "trace-events-page-read-http-error-root-cause": "trace events HTTP 错误根因", "trace-events-page-read-requestfailed-root-cause": "trace events 网络失败根因", "observer-command-failed": "观察器控制命令失败", "runtime-requestfailed": "运行时请求失败", "runtime-console-alerts": "运行时控制台告警", "browser-console-or-page-errors": "浏览器控制台或页面错误", "browser-timeout": "页面 readiness 超时", "target-page-readiness-timeout": "页面 readiness 超时", "network-or-api-fetch-bug": "网络或 API 请求异常", }; return labels[normalized] || String(code || "finding"); } function displayFindingSummary(code, summary) { const normalized = String(code || "").toLowerCase(); const summaries = { "quick-verify-no-business-turn": "quick verify 没有形成 sendPrompt、session、trace rows 或 Final Response;不能把公开 dashboard 200 当作 HWLAB 恢复证据。", "session-rail-title-fallback-root-cause": "会话列表可见 Session ses_* fallback 标题;根因定位到 session list projection/read model 或 rail 数据绑定缺稳定 title/preview。", "trace-events-page-read-404-root-cause": "trace events page read 返回 404;根因定位到 /v1/workbench/traces/:traceId/events API 分页/read-model 合约,早于 DOM 渲染。", "trace-events-page-read-http-error-root-cause": "trace events page read 返回 HTTP 错误;根因定位到 trace-events API 路径,不是通用前端渲染失败。", "trace-events-page-read-requestfailed-root-cause": "trace events page read 在浏览器网络层失败;monitor 已定位路径,但需要 OTel/API 字段确认后端根因。", "observer-command-failed": "observe control command 失败,需要查看 observer timeline 和 failed command 文件确认是 readiness、session API、超时还是 runner shutdown。", "runtime-requestfailed": "页面运行时存在请求失败,需要按路径聚合确认是 asset/provenance 噪声、public origin、auth/session 还是 Workbench API。", "runtime-console-alerts": "页面控制台出现告警,需要结合 run detail 的 network/console 证据判断是否影响业务 turn。", "browser-console-or-page-errors": "浏览器页面报错,需要先看 finalUrl、readiness、session create 和 API 证据,再判断是否是前端缺陷。", "browser-timeout": "页面或 Workbench readiness 超时,不应默认判断为浏览器安装环境问题。", "target-page-readiness-timeout": "页面或 Workbench readiness 超时,不应默认判断为浏览器安装环境问题。", }; const translated = summaries[normalized]; if (!translated) return String(summary || ""); const source = String(summary || "").trim(); return source && !source.includes(translated) ? `${translated} 原始摘要: ${source}` : translated; } function findingNextAction(code) { const normalized = String(code || "").toLowerCase(); const actions = { "quick-verify-no-business-turn": "下一步: 打开该 run 的 turn-summary/trace-frame,并用 CLI 对照命令确认没有业务 turn。", "session-rail-title-fallback-root-cause": "下一步: 查同 run 的 OTel session_list_read fallbackTitleCount/fallbackTitleRatio,修 session list projection/read model title/preview。", "trace-events-page-read-404-root-cause": "下一步: 查同 trace 的 OTel trace_events_read sinceSeq/range/hasMore/fullTraceLoaded,修 trace-events 分页合约或 instrumentation。", "trace-events-page-read-http-error-root-cause": "下一步: 按 traceId/afterProjectedSeq 对齐 OTel trace_events_read 和 HTTP route span。", "trace-events-page-read-requestfailed-root-cause": "下一步: 先排除 observer refresh/navigation;若非刷新噪声,补 HTTP route span 的 traceId/status/afterProjectedSeq 字段。", "observer-command-failed": "下一步: 查看 observe collect timeline 和 failed command 文件,定位失败阶段。", "runtime-requestfailed": "下一步: 按请求路径聚合失败,区分网络、auth/session、API 或静态资源问题。", "runtime-console-alerts": "下一步: 结合 console 样本与业务 trace 判断是否为阻塞级。", "browser-console-or-page-errors": "下一步: 先查 Workbench readiness 和 session create,再决定是否修前端。", "browser-timeout": "下一步: 查 finalUrl、loading、session create、network requestfailed 和 reportPath,不先改浏览器安装。", "target-page-readiness-timeout": "下一步: 查 finalUrl、loading、session create、network requestfailed 和 reportPath,不先改浏览器安装。", }; return actions[normalized] || "下一步: 打开最近运行详情,并用 CLI 对照命令复核同一 run/observer/report。"; } function findingRootCauseText(item) { const rootCause = item.rootCause || ""; const status = item.rootCauseStatus || ""; const confidence = item.rootCauseConfidence || ""; if (!rootCause && !status && !confidence) return ""; return [ rootCause ? `rootCause=${rootCause}` : "", status ? `status=${status}` : "", confidence ? `confidence=${confidence}` : "", ].filter(Boolean).join(" · "); } function findingEvidenceText(item) { const text = item.evidenceSummary || ""; return text ? shortText(String(text), 220) : ""; } function formatSeveritySummary(counts) { const entries = Object.entries(counts || {}).filter(([, value]) => Number(value || 0) > 0); if (entries.length === 0) return "无"; return entries.map(([key, value]) => `${displaySeverity(key)} ${formatNumber(Number(value || 0))}`).join(" · "); } function formatNumber(value) { return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); } function shortText(value, limit) { const text = String(value || ""); return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`; } function formatRelative(iso) { const ms = Date.parse(String(iso || "")); if (!Number.isFinite(ms)) return "-"; const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); if (seconds < 5) return "刚刚"; if (seconds < 60) return `${seconds} 秒前`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} 分前`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} 时前`; return `${Math.floor(hours / 24)} 天前`; } function escapeHtml(value) { return String(value ?? "") .replace(/&/gu, "&") .replace(//gu, ">") .replace(/"/gu, """) .replace(/'/gu, "'"); } function escapeAttr(value) { return escapeHtml(value); }