// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery. // Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering. /** * @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 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"), 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"), 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"), 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"), 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"), detailSubtitle: document.getElementById("detail-subtitle"), detailContent: document.getElementById("detail-content"), }; 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(), }; 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, }); 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.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(); 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: "" }; writeFindingFiltersToControls(); syncLocationQuery(); loadDashboard({ silent: false }); }); document.addEventListener("visibilitychange", () => { if (!document.hidden && autoRefresh.enabled()) loadDashboard({ silent: true }); }); hydrateControls(); writeFiltersToControls(); writeFindingFiltersToControls(); loadDashboard({ silent: false }).catch((error) => renderError(error)); function createDashboardApi() { return { /** @returns {Promise} */ overview: () => getJson("/api/overview"), /** @returns {Promise} */ runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`), /** @returns {Promise} */ findings: (filters) => getJson(`/api/findings?${findingsQuery(filters)}`), /** @returns {Promise} */ runDetail: (runId) => getJson(`/api/runs/${encodeURIComponent(runId)}`), runViews: (runId, view = null) => { const query = new URLSearchParams({ maxBytes: "24000" }); if (view) query.set("view", view); return getJson(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`); }, }; } 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; } 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.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)}`; 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)} 更新` : "-"; 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); const scheduler = overview.scheduler || {}; const heartbeat = scheduler.heartbeat || {}; refs.scheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "未知"; const maxSeconds = overview.targetValidation?.maxSeconds ?? 120; refs.budget.textContent = `targetValidation 预算 ${maxSeconds}s`; const checks = overview.health?.checks || {}; 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 ? "生效中" : "未生效"); } function renderTimeline() { const visibleRuns = state.runs.slice(0, 12); 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 title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${run.updatedAt ? formatRelative(run.updatedAt) : "-"}`; return ``; }).join(""); for (const node of refs.timeline.querySelectorAll("[data-run-id]")) { node.addEventListener("click", () => selectRun(node.dataset.runId)); } } function renderRuns() { refs.runsCount.textContent = `${formatNumber(state.runs.length)} 条可见`; if (state.runs.length === 0) { refs.runsBody.innerHTML = '暂无运行'; return; } refs.runsBody.innerHTML = state.runs.map((run) => { const runId = run.runId || run.id || "-"; const selected = state.selectedRunId === runId ? " selected-row" : ""; return `
run${escapeHtml(runId)}
observer${escapeHtml(run.observerId || "-")}
${escapeHtml(displayStatus(run.status))} ${escapeHtml(run.scenarioId || "-")} ${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` ${escapeHtml(displaySeverity(run.maxSeverity))}` : ""} ${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}${escapeHtml(run.maintenance ? "维护窗口" : "")} `; }).join(""); 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.findings.length === 0) { refs.findingsList.innerHTML = '
暂无发现项
'; return; } const groups = groupedFindingsBySeverity(state.findings); refs.findingsList.innerHTML = groups.map((group, groupIndex) => { const open = group.key === "red" || groupIndex === 0; const visibleItems = group.items.slice(0, group.key === "red" ? 8 : 5); const hiddenCount = Math.max(0, group.items.length - visibleItems.length); return `
${escapeHtml(displaySeverity(group.key))} ${formatNumber(group.totalCount)} ${formatNumber(group.items.length)} 组${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组通过过滤查看` : ""}
${visibleItems.map(renderFindingItem).join("")}
`; }).join(""); for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) { button.addEventListener("click", () => selectRun(button.dataset.openFindingRun)); } for (const button of refs.findingsList.querySelectorAll("[data-finding-filter]")) { button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || "")); } } function renderFindingItem(item) { const code = item.code || item.findingId || "finding"; const latestRunId = item.latestRunId || "-"; const hasLatestRun = latestRunId !== "-"; const codeLabel = displayFindingCode(code); return `
${escapeHtml(codeLabel)}${codeLabel === code ? "" : ` ${escapeHtml(code)}`} ${escapeHtml(displaySeverity(item.severity))}
${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}
次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}
run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}
${escapeHtml(findingNextAction(code))}
`; } 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((a, b) => Number(b.count || 0) - Number(a.count || 0)), totalCount: items.reduce((sum, item) => sum + Number(item.count || 0), 0), })) .sort((a, b) => severityRank(a.key, severityOrder) - severityRank(b.key, severityOrder)); } 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); refs.findingAggregation.innerHTML = [ aggregationGroup("严重级别", severityEntries.map((item) => ({ ...item, label: displaySeverity(item.key) })), "severity"), aggregationGroup("代码", codeEntries.map((item) => ({ ...item, label: displayFindingCode(item.key) })), "code"), aggregationGroup("场景", scenarioEntries, "scenario"), `
窗口
`, ].join(""); for (const button of refs.findingAggregation.querySelectorAll("[data-finding-filter]")) { button.addEventListener("click", () => applyFindingFilter(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 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 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(); syncLocationQuery(); loadDashboard({ silent: false }); } function renderDetail() { if (!state.selectedRunId || !state.runDetail) { refs.detailSubtitle.textContent = "未选择运行"; refs.detailContent.innerHTML = '
请选择一条运行
'; return; } 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 = [ 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), "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 || "-"], ]), ].join(""); bindDetailControls(); } function detailBlock(title, rows, className = "") { const visibleRows = rows.length > 0 ? rows : [["-", "-"]]; return `
${escapeHtml(title)}
${ visibleRows.map(([key, value]) => `
${escapeHtml(key)}: ${escapeHtml(shortText(value, 180))}
`).join("") }
`; } function detailFindings(findings) { if (findings.length === 0) return detailBlock("运行发现项", [["status", "无"]], "detail-block-wide"); return `
运行发现项
${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(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 || "-"], ], "detail-block-wide"); } function detailCommands(commands) { const rows = [ ["summary", commands.summary || "-"], ["turn-summary", commands.turnSummary || "-"], ["trace-frame", commands.traceFrame || "-"], ]; return `
CLI 对照命令
${rows.map(([label, command]) => `
${escapeHtml(label)}${escapeHtml(command)}
`).join("")}
`; } function detailTurnSummary(view) { if (!view) return detailBlock("多轮摘要 - 第一层", [["status", "未索引"]], "detail-block-wide"); if (view.ok === false) return detailBlock("多轮摘要 - 第一层", [["status", view.error || "不可用"]], "detail-block-wide"); 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 || "-"], ], "detail-block-wide"); } 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(); }); } } 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 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", }; 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(), }; 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"; } function writeFindingFiltersToControls() { refs.findingFilterSeverity.value = state.findingFilters.severity || ""; refs.findingFilterWindow.value = state.findingFilters.window || ""; 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 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); 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", "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 恢复证据。", "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。", "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 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); }