Files
pikasTech-unidesk/scripts/assets/web-probe-sentinel-dashboard/dashboard.js
T

997 lines
45 KiB
JavaScript

// 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<string, number>, severityCounts?: Record<string, number>, freshness?: Record<string, unknown>, 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<string, unknown>|null}} SentinelRunView
* @typedef {{ok?: boolean, run?: SentinelRun, summary?: Record<string, unknown>, findings?: Record<string, unknown>[], artifacts?: Record<string, unknown>, commands?: Record<string, string>, redaction?: Record<string, unknown>, 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<SentinelOverview>} */
overview: () => getJson("/api/overview"),
/** @returns {Promise<SentinelRunsResponse>} */
runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`),
/** @returns {Promise<SentinelFindingsResponse>} */
findings: (filters) => getJson(`/api/findings?${findingsQuery(filters)}`),
/** @returns {Promise<SentinelRunDetail>} */
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 = '<div class="empty-state">加载中</div>';
try {
const [detail, views] = await Promise.all([
dashboardApi.runDetail(runId),
loadRunViews(runId),
]);
state.runDetail = detail;
state.runViews = views;
renderDetail();
} catch (error) {
refs.detailContent.innerHTML = `<div class="empty-state">${escapeHtml(error.message || String(error))}</div>`;
}
}
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 = '<div class="empty-state">暂无时间线</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) : "-"}`;
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>
</button>`;
}).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 = '<tr><td class="empty-state" colspan="5">暂无运行</td></tr>';
return;
}
refs.runsBody.innerHTML = state.runs.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>
<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>
</tr>`;
}).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 = '<div class="empty-state">暂无发现项</div>';
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 `<details class="finding-group finding-group-${escapeAttr(group.key)}" ${open ? "open" : ""}>
<summary>
<span>${escapeHtml(displaySeverity(group.key))}</span>
<strong>${formatNumber(group.totalCount)}</strong>
<small>${formatNumber(group.items.length)}${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组通过过滤查看` : ""}</small>
</summary>
<div class="finding-group-list">${visibleItems.map(renderFindingItem).join("")}</div>
</details>`;
}).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 `<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();
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 = '<div class="empty-state compact">暂无发现聚合</div>';
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"),
`<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 || ""));
}
}
function aggregationGroup(label, entries, filterKey) {
const chips = entries.length === 0
? '<span class="finding-meta">-</span>'
: entries.map((entry) => `<button type="button" class="filter-chip" data-finding-filter="${escapeAttr(filterKey)}" data-filter-value="${escapeAttr(entry.key)}">${escapeHtml(entry.label || entry.key)} ${formatNumber(entry.count)}</button>`).join("");
return `<div class="aggregation-group"><span>${escapeHtml(label)}</span>${chips}</div>`;
}
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 = '<div class="empty-state">请选择一条运行</div>';
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 `<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 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>
</article>`;
}
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 `<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>
</article>`;
}
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 `<article class="detail-block detail-block-wide"><strong>多轮摘要 - 第一层</strong>
<div class="view-note">${escapeHtml(note)}</div>
<pre class="detail-pre">${escapeHtml(text || "-")}</pre>
</article>`;
}
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 `<article class="detail-block detail-block-wide 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>
<div class="trace-choice-list">${choices.map((choice, index) => `<button type="button" class="trace-choice ${index === selectedIndex ? "active" : ""}" data-trace-choice-index="${escapeAttr(String(index))}">
<span>${escapeHtml(choice.label)}</span>
<small>${escapeHtml(choice.meta)}</small>
</button>`).join("")}</div>
</section>
<section class="trace-frame-view" aria-label="trace-frame 文字视图">
<div class="view-note">已选择: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}</div>
<pre class="detail-pre trace-frame-pre">${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame 不可用" : "trace-frame 未索引"))}</pre>
${finalResponseBlock(traceView)}
<div class="trace-source-note mono">source=${escapeHtml(commands.traceFrame || "-")} · analyzer finding 不改写此文字证据</div>
</section>
</div>
</article>`;
}
function finalResponseBlock(traceView) {
if (!traceView) {
return `<section class="final-response-block unavailable"><strong>Final Response</strong><div>trace-frame 未索引</div></section>`;
}
if (traceView.ok === false) {
return `<section class="final-response-block unavailable"><strong>Final Response</strong><div>${escapeHtml(traceView.error || "trace-frame 不可用")}</div></section>`;
}
const block = traceView.finalResponse || {};
const text = block.empty === true ? "(空内容)" : redactDisplayText(block.text || "");
const bytes = Number(block.byteCount || 0);
return `<section class="final-response-block ${block.empty === true ? "empty" : ""}">
<strong>Final Response</strong>
<div class="view-note">${block.empty === true ? "空内容" : "有内容"} · ${formatNumber(bytes)} bytes · 已脱敏</div>
<pre class="final-response-text">${escapeHtml(text || "(空内容)")}</pre>
</section>`;
}
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, "&amp;")
.replace(/</gu, "&lt;")
.replace(/>/gu, "&gt;")
.replace(/"/gu, "&quot;")
.replace(/'/gu, "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value);
}