From e2890bac646fc19417e4d9fe96cd6422ce3af3e5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 06:14:05 +0000 Subject: [PATCH] feat: unify sentinel monitor checks --- .../check-catalog.yaml | 425 ++++++++++++++++++ .../report-views.auth-session-switch.yaml | 1 + .../report-views.yaml | 1 + .../monitor-web.css | 64 ++- .../monitor-web.js | 123 +++-- .../src/hwlab-node-web-sentinel-service.ts | 190 ++++++-- 6 files changed, 734 insertions(+), 70 deletions(-) create mode 100644 config/hwlab-web-probe-sentinel/check-catalog.yaml diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml new file mode 100644 index 00000000..81a99b24 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml @@ -0,0 +1,425 @@ +version: 1 +kind: HwlabWebProbeSentinelCheckCatalog +metadata: + id: web-probe-sentinel-check-catalog + owner: UniDesk + specRef: PJ2026-01060508 + issue: 1203 +sentinel: + checkCatalog: + terminology: + entity: check + zh: 监测项 + codePrefix: WBC + levels: + - critical + - error + - warning + - info + items: + - code: WBC-001 + id: workbench-terminal-api-dom-not-refreshed-in-place + level: error + titleZh: 终端未原地刷新 + summaryZh: API 已变化但终端 DOM 未同步。 + blocking: true + order: 10 + - code: WBC-002 + id: workbench-terminal-trace-not-hydrated-in-place + level: error + titleZh: 终端 Trace 未水合 + summaryZh: Trace 数据存在但终端区域未原地渲染。 + blocking: true + order: 20 + - code: WBC-003 + id: quick-verify-no-business-turn + level: error + titleZh: 快速验证无业务回合 + summaryZh: 快速验证未形成稳定会话、trace 或最终回复。 + blocking: true + order: 30 + - code: WBC-004 + id: quick-verify-command-sequence-failed + level: error + titleZh: 快速验证命令失败 + summaryZh: 快速验证命令序列未能完成。 + blocking: true + order: 40 + - code: WBC-005 + id: quick-verify-observer-start-failed + level: error + titleZh: 观察器启动失败 + summaryZh: 快速验证前观察器未成功启动。 + blocking: true + order: 50 + - code: WBC-006 + id: quick-verify-account-secret-missing + level: error + titleZh: 账号密钥缺失 + summaryZh: 快速验证所需账号或 Secret 未配置。 + blocking: true + order: 60 + - code: WBC-007 + id: prompt-chat-submit-failed + level: error + titleZh: Prompt 提交失败 + summaryZh: sendPrompt 未观察到成功的 chat 请求。 + blocking: true + order: 70 + - code: WBC-008 + id: observer-command-failed + level: error + titleZh: 观察命令失败 + summaryZh: web-probe observe 命令执行失败。 + blocking: true + order: 80 + - code: WBC-009 + id: route-active-session-mismatch + level: error + titleZh: 路由会话不一致 + summaryZh: URL 路由会话与活动会话不一致。 + blocking: true + order: 90 + - code: WBC-010 + id: workbench-message-order-user-clustered-after-navigation + level: warning + titleZh: 导航后消息顺序异常 + summaryZh: 导航或刷新后用户消息聚集,需核对会话投影。 + blocking: false + order: 100 + - code: WBC-011 + id: cross-page-projection-divergence + level: error + titleZh: 跨页面投影分叉 + summaryZh: control 与 observer 在同一会话看到不同投影。 + blocking: true + order: 110 + - code: WBC-012 + id: cross-page-projection-transient-divergence + level: info + titleZh: 跨页面短暂分叉 + summaryZh: 页面切换附近出现短暂投影差异。 + blocking: false + order: 120 + - code: WBC-013 + id: cross-page-projection-controlled-navigation-hydration + level: info + titleZh: 导航水合期差异 + summaryZh: 导航水合期间 control 与 observer 暂时不同步。 + blocking: false + order: 130 + - code: WBC-014 + id: cross-page-projection-app-shell-not-ready + level: info + titleZh: Shell 未就绪差异 + summaryZh: 投影差异由页面 Shell 未挂载解释。 + blocking: false + order: 140 + - code: WBC-015 + id: cross-page-trace-visibility-divergence + level: info + titleZh: Trace 可见性差异 + summaryZh: 两页只在可见 Trace 行数上不同。 + blocking: false + order: 150 + - code: WBC-016 + id: workbench-app-shell-not-ready + level: error + titleZh: Workbench Shell 未就绪 + summaryZh: Workbench 路由已加载但应用 Shell 未挂载。 + blocking: true + order: 160 + - code: WBC-017 + id: workbench-app-shell-transient-not-ready + level: info + titleZh: Shell 短暂未就绪 + summaryZh: Workbench Shell 启动期间短暂不可用。 + blocking: false + order: 170 + - code: WBC-018 + id: trace-without-terminal + level: warning + titleZh: Trace 缺少终端态 + summaryZh: 已看到 Trace 行但未看到终端状态。 + blocking: false + order: 180 + - code: WBC-019 + id: turn-trace-id-missing + level: error + titleZh: 回合缺少 Trace ID + summaryZh: Code Agent 回合可见但缺少可追踪 Trace ID。 + blocking: true + order: 190 + - code: WBC-020 + id: trace-assistant-message-duplicates-final-response + level: warning + titleZh: Trace 回复重复 + summaryZh: Trace Frame 渲染了重复助手最终回复。 + blocking: false + order: 200 + - code: WBC-021 + id: final-response-flicker + level: error + titleZh: 最终回复闪烁 + summaryZh: Final Response 在采样期间不稳定。 + blocking: true + order: 210 + - code: WBC-022 + id: round-completion-final-response-missing + level: error + titleZh: 完成后无最终回复 + summaryZh: 回合完成后缺少最终回复内容。 + blocking: true + order: 220 + - code: WBC-023 + id: trace-events-page-read-404-root-cause + level: error + titleZh: Trace 分页 404 + summaryZh: Trace events 分页读取命中 404。 + blocking: true + order: 230 + - code: WBC-024 + id: trace-events-page-read-http-error-root-cause + level: error + titleZh: Trace 分页 HTTP 错误 + summaryZh: Trace events 分页读取返回 HTTP 错误。 + blocking: true + order: 240 + - code: WBC-025 + id: trace-events-page-read-requestfailed-root-cause + level: warning + titleZh: Trace 分页网络失败 + summaryZh: Trace events 分页读取出现 requestfailed。 + blocking: false + order: 250 + - code: WBC-026 + id: runtime-http-errors + level: warning + titleZh: 页面 HTTP 错误 + summaryZh: 观察期间页面请求返回 HTTP 错误状态。 + blocking: false + order: 260 + - code: WBC-027 + id: runtime-requestfailed + level: warning + titleZh: 页面请求失败 + summaryZh: 浏览器捕获 requestfailed 事件。 + blocking: false + order: 270 + - code: WBC-028 + id: runtime-dom-diagnostics + level: warning + titleZh: DOM 诊断文本 + summaryZh: 页面 DOM 中出现错误或警告类诊断文本。 + blocking: false + order: 280 + - code: WBC-029 + id: runtime-execution-errors + level: error + titleZh: 运行面执行错误 + summaryZh: Workbench 渲染了执行失败或错误行。 + blocking: true + order: 290 + - code: WBC-030 + id: runtime-console-alerts + level: warning + titleZh: Console 告警 + summaryZh: 浏览器 console 捕获警告或错误。 + blocking: false + order: 300 + - code: WBC-031 + id: browser-console-or-page-errors + level: warning + titleZh: 页面运行错误 + summaryZh: pageerror 或 runner error 被捕获。 + blocking: false + order: 310 + - code: WBC-032 + id: page-performance-slow-same-origin-api + level: error + titleZh: 同源 API 过慢 + summaryZh: 同源 API 资源耗时超过 YAML 预算。 + blocking: true + order: 320 + - code: WBC-033 + id: page-performance-slow-long-lived-stream-open + level: error + titleZh: 长连接打开过慢 + summaryZh: 长连接打开耗时超过 YAML 预算。 + blocking: true + order: 330 + - code: WBC-034 + id: page-performance-long-lived-streams + level: info + titleZh: 长连接上下文 + summaryZh: 页面存在同源长连接,作为性能上下文保留。 + blocking: false + order: 340 + - code: WBC-035 + id: page-provenance-segments + level: info + titleZh: 页面资产分段 + summaryZh: 观察跨越多个页面资产版本段。 + blocking: false + order: 350 + - code: WBC-036 + id: natural-api-dom-lag-baseline + level: info + titleZh: API-DOM 基线 + summaryZh: 已收集自然 API 与 DOM 滞后基线。 + blocking: false + order: 360 + - code: WBC-037 + id: natural-api-dom-lag-candidates + level: info + titleZh: API-DOM 滞后候选 + summaryZh: 自然 API 与 DOM 投影滞后候选样本。 + blocking: false + order: 370 + - code: WBC-038 + id: turn-timing-total-elapsed-zero-reset + level: warning + titleZh: 总耗时归零 + summaryZh: Code Agent 总耗时从非零跳回 0。 + blocking: false + order: 380 + - code: WBC-039 + id: turn-timing-total-elapsed-decrease + level: warning + titleZh: 总耗时回退 + summaryZh: Code Agent 总耗时在相邻样本间降低。 + blocking: false + order: 390 + - code: WBC-040 + id: turn-timing-total-elapsed-forward-jump + level: warning + titleZh: 总耗时前跳 + summaryZh: Code Agent 总耗时增长快于采样间隔。 + blocking: false + order: 400 + - code: WBC-041 + id: turn-timing-terminal-elapsed-growth + level: warning + titleZh: 终态耗时增长 + summaryZh: 回合终态后总耗时仍继续变化。 + blocking: false + order: 410 + - code: WBC-042 + id: turn-timing-recent-update-sawtooth-jump + level: warning + titleZh: 最近更新跳变 + summaryZh: 最近更新时间显示出现锯齿跳变。 + blocking: false + order: 420 + - code: WBC-043 + id: turn-elapsed-severe-timeout + level: warning + titleZh: 回合耗时超阈值 + summaryZh: 回合总耗时超过 YAML 配置告警阈值。 + blocking: false + order: 430 + - code: WBC-044 + id: page-loading-visible-over-budget + level: error + titleZh: 加载态超时 + summaryZh: 页面可见加载态持续时间超过 YAML 预算。 + blocking: true + order: 440 + - code: WBC-045 + id: page-loading-concurrent + level: info + titleZh: 多加载态并发 + summaryZh: 同一采样点出现多个加载中指示。 + blocking: false + order: 450 + - code: WBC-046 + id: session-rail-title-fallback-root-cause + level: error + titleZh: 会话标题回退 + summaryZh: 会话栏标题从 facts 回退,已定位为根因。 + blocking: true + order: 460 + - code: WBC-047 + id: scroll-jump-top + level: warning + titleZh: 滚动跳顶 + summaryZh: 页面滚动位置无命令触发地跳到顶部附近。 + blocking: false + order: 470 + - code: WBC-048 + id: code-agent-card-duration-mismatch + level: warning + titleZh: 卡片耗时不一致 + summaryZh: Code Agent 卡片耗时与观测回合耗时不一致。 + blocking: false + order: 480 + - code: WBC-049 + id: round-completion-elapsed-mismatch + level: warning + titleZh: 完成耗时不一致 + summaryZh: 回合完成状态前后的耗时显示不一致。 + blocking: false + order: 490 + - code: WBC-050 + id: round-completion-post-timing-change + level: warning + titleZh: 完成后耗时变化 + summaryZh: 回合完成后仍出现耗时变化。 + blocking: false + order: 500 + - code: WBC-051 + id: round-completion-recent-update-still-visible + level: info + titleZh: 完成后仍显示最近更新 + summaryZh: 回合完成后仍保留最近更新时间显示。 + blocking: false + order: 510 + - code: WBC-052 + id: no-samples + level: error + titleZh: 无采样数据 + summaryZh: 观察器未产生任何可分析样本。 + blocking: true + order: 520 + - code: WBC-053 + id: jsonl-read-issues + level: error + titleZh: JSONL 读取异常 + summaryZh: analyzer 读取或解析 JSONL 样本失败。 + blocking: true + order: 530 + - code: WBC-054 + id: prompt-routed-to-steer + level: warning + titleZh: Prompt 走 steer + summaryZh: sendPrompt 被路由到 steer,需确认上一回合状态。 + blocking: false + order: 540 + - code: WBC-055 + id: tool-runner-heartbeat-stale + level: error + titleZh: Runner 心跳过期 + summaryZh: web-probe runner 心跳超过预算未更新。 + blocking: true + order: 550 + - code: WBC-056 + id: tool-pending-commands-unconsumed + level: error + titleZh: 命令未消费 + summaryZh: runner 存在未消费的待执行命令。 + blocking: true + order: 560 + - code: WBC-057 + id: tool-commands-abandoned + level: info + titleZh: 命令被遗留 + summaryZh: 历史命令被遗留,作为工具上下文保留。 + blocking: false + order: 570 + - code: WBC-058 + id: tool-runner-force-stopped + level: info + titleZh: Runner 被强制停止 + summaryZh: runner 由控制面强制停止,作为观察上下文保留。 + blocking: false + order: 580 diff --git a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml index c292f1e4..6ffdb0a8 100644 --- a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml @@ -15,6 +15,7 @@ sentinel: pageSize: 20 maxPageSize: 100 rawAccess: explicit-only + checkCatalogRef: config/hwlab-web-probe-sentinel/check-catalog.yaml#sentinel.checkCatalog redaction: prompt: hash-and-byte-count assistantFinal: summary-and-hash diff --git a/config/hwlab-web-probe-sentinel/report-views.yaml b/config/hwlab-web-probe-sentinel/report-views.yaml index 9202f2e6..923fce86 100644 --- a/config/hwlab-web-probe-sentinel/report-views.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.yaml @@ -15,6 +15,7 @@ sentinel: pageSize: 20 maxPageSize: 100 rawAccess: explicit-only + checkCatalogRef: config/hwlab-web-probe-sentinel/check-catalog.yaml#sentinel.checkCatalog redaction: prompt: hash-and-byte-count assistantFinal: summary-and-hash diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css index 56f509f3..9fdcedf6 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css @@ -27,14 +27,14 @@ html, body, #monitor-web-root { - height: 100%; + min-height: 100%; margin: 0; } body { background: var(--bg); color: var(--text); - overflow: hidden; + overflow: auto; } button, @@ -45,11 +45,10 @@ input { .monitor-shell { display: flex; - height: 100dvh; - min-height: 0; + min-height: 100dvh; flex-direction: column; gap: 10px; - overflow: hidden; + overflow: visible; padding: 12px; } @@ -462,7 +461,7 @@ select { .timeline-list { display: grid; - max-height: 150px; + max-height: 300px; gap: 7px; overflow: auto; padding-right: 2px; @@ -549,9 +548,9 @@ select { display: grid; grid-template-columns: minmax(260px, 0.82fr) minmax(420px, 1.34fr) minmax(300px, 1fr); gap: 10px; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; + flex: 0 0 auto; + min-height: clamp(680px, calc(100dvh - 220px), 980px); + overflow: visible; } .pane { @@ -615,6 +614,7 @@ select { .finding-list, .detail-stack { display: grid; + align-content: start; gap: 8px; } @@ -658,6 +658,18 @@ select { background: var(--green); } +.check-code { + flex: 0 0 auto; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: #ffffff; + color: var(--ink); + padding: 2px 6px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 11px; + line-height: 1.2; +} + .tag { display: inline-flex; align-items: center; @@ -702,6 +714,40 @@ select { background: #ffffff; } +.summary-list { + display: grid; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.summary-row { + display: grid; + grid-template-columns: minmax(86px, 0.35fr) minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 34px; + padding: 7px 10px; + border-bottom: 1px solid var(--line); + font-size: 12px; +} + +.summary-row:last-child { + border-bottom: 0; +} + +.summary-row span { + color: var(--muted); +} + +.summary-row strong { + min-width: 0; + overflow-wrap: anywhere; + font-size: 12px; + font-weight: 600; +} + pre { max-height: 240px; margin: 0; diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js index c0136040..c8c9ec6f 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js @@ -71,13 +71,18 @@ createApp({ timeLabel: formatDate(rawTime), absoluteTime: formatAbsoluteDate(rawTime), reportSha: shortHash(run.reportJsonSha256 || run.report_json_sha256 || run.reportSha256 || ""), - title: `${shortId(run.id)} ${formatAbsoluteDate(rawTime)} 红色 ${red} 警告 ${warning} 总量 ${total}`, + title: `${shortId(run.id)} ${formatAbsoluteDate(rawTime)} 错误 ${red} 警告 ${warning} 总量 ${total}`, }; })); const timelineRuns = computed(() => runs.value.slice(0, 16)); const rootCauseFindings = computed(() => { - const rows = findings.value.filter((item) => item.rootCause || item.nextAction || ["red", "warning"].includes(severityClass(item))); - return rows.slice(0, 14); + const rows = findings.value.filter((item) => item.checkRegistered !== false || item.rootCause || item.nextAction || ["red", "warning", "info"].includes(severityClass(item))); + return rows.slice(0, 32); + }); + const visibleCheckFindings = computed(() => { + const needle = findingFilter.value.trim().toLowerCase(); + const rows = needle.length === 0 ? rootCauseFindings.value : rootCauseFindings.value.filter((item) => findingSearchText(item).includes(needle)); + return rows.slice(0, 24); }); const cadence = computed(() => { const intervalMs = Number(overview.value?.scheduler?.intervalMs || 0); @@ -220,6 +225,7 @@ createApp({ trendDots, timelineRuns, rootCauseFindings, + visibleCheckFindings, cadence, healthChecks, loadAll, @@ -238,7 +244,10 @@ createApp({ shortId, rootCauseText, findingTitle, + findingCode, + levelLabel, detailSummaryText, + detailSummaryRows, commandSummary, statusLabel, }; @@ -292,13 +301,13 @@ createApp({
-

红色 / 警告数量曲线

+

错误 / 警告监测项曲线

按运行更新时间展示最近 {{ trendRows.length }} 次变化

{{ cadence.stale ? "非阻塞报警" : "新鲜" }}
- + @@ -335,15 +344,15 @@ createApp({ {{ shortId(hoveredTrendDot.runId) }} {{ hoveredTrendDot.absoluteTime }} 状态 {{ hoveredTrendDot.status }} - 红色 {{ hoveredTrendDot.red }} / 警告 {{ hoveredTrendDot.warning }} / 总量 {{ hoveredTrendDot.total }} + 错误 {{ hoveredTrendDot.red }} / 警告 {{ hoveredTrendDot.warning }} / 总量 {{ hoveredTrendDot.total }} report {{ hoveredTrendDot.reportSha }}
暂无运行数据
- 红色 {{ redCount({ severityCounts: severityTotals }) }} + 错误 {{ redCount({ severityCounts: severityTotals }) }} 警告 {{ warningCount({ severityCounts: severityTotals }) }} - 发现总量 {{ findingCount({ findingCount: overview?.latestRun?.findingCount, severityCounts: severityTotals }) }} + 监测项总量 {{ findingCount({ findingCount: overview?.latestRun?.findingCount, severityCounts: severityTotals }) }} {{ cadence.alert }}
@@ -384,7 +393,7 @@ createApp({ {{ cadence.latestAge >= 0 ? formatDuration(cadence.latestAge) : "-" }}
- 红色 + 错误 {{ redCount({ severityCounts: severityTotals }) }}
@@ -412,7 +421,7 @@ createApp({ +
- {{ findingTitle(item) }} - {{ item.runCount || item.count || 1 }} + {{ findingCode(item) }}{{ findingTitle(item) }} + {{ levelLabel(item) }} · {{ item.runCount || item.count || 1 }}

{{ rootCauseText(item) }}

-

方案: {{ item.nextAction }}

+

处理: {{ item.nextAction }}

-
暂无已归因发现
+
暂无匹配监测项
@@ -565,7 +579,7 @@ function findingCount(item) { } function severityClass(item) { - const explicit = String(item?.maxSeverity || item?.severity || "").toLowerCase(); + const explicit = String(item?.maxSeverity || item?.checkLevel || item?.severity || item?.level || "").toLowerCase(); if (["red", "critical", "error", "blocked"].includes(explicit)) return "red"; if (["warning", "warn", "amber"].includes(explicit)) return "warning"; if (["info", "notice"].includes(explicit)) return "info"; @@ -617,18 +631,73 @@ function clamp(value, min, max) { } function rootCauseText(item) { - return item?.rootCause || item?.evidenceSummary || item?.summary || "尚未记录根因,等待下一次 OTel/报告归因。"; + if (item?.rootCause) return `根因: ${item.rootCause}`; + return item?.checkSummaryZh || item?.summary || item?.evidenceSummary || "尚未记录根因,等待下一次 OTel/报告归因。"; } function findingTitle(item) { - return item?.code || item?.findingId || item?.scenarioId || item?.latestRunId || "finding"; + return item?.checkTitleZh || item?.check?.titleZh || item?.findingId || item?.code || item?.scenarioId || item?.latestRunId || "未登记监测项"; +} + +function findingCode(item) { + return item?.checkCode || item?.check?.code || "未登记"; +} + +function levelLabel(item) { + const value = String(item?.checkLevel || item?.severity || item?.level || "").toLowerCase(); + if (["critical", "red"].includes(value)) return "严重"; + if (["error", "blocked", "failed"].includes(value)) return "错误"; + if (["warning", "warn", "amber"].includes(value)) return "警告"; + if (["info", "notice"].includes(value)) return "信息"; + return "未知"; +} + +function findingSearchText(item) { + return [ + item?.checkCode, + item?.check?.code, + item?.checkTitleZh, + item?.check?.titleZh, + item?.checkLevel, + item?.severity, + item?.findingId, + item?.code, + item?.summary, + item?.checkSummaryZh, + item?.rootCause, + item?.evidenceSummary, + item?.nextAction, + item?.scenarioId, + item?.latestRunId, + ].filter((value) => value !== null && value !== undefined).join(" ").toLowerCase(); } function detailSummaryText(detail) { - if (!detail) return "加载详情中"; - if (detail.ok === false) return detail.error || "详情不可用"; - const summary = detail.summary && Object.keys(detail.summary).length > 0 ? detail.summary : detail.run; - return JSON.stringify(summary || {}, null, 2); + return detailSummaryRows(detail).map((row) => `${row.label}: ${row.value}`).join("\n"); +} + +function detailSummaryRows(detail) { + if (!detail) return [{ key: "loading", label: "状态", value: "加载详情中" }]; + if (detail.ok === false) return [{ key: "error", label: "状态", value: detail.error || "详情不可用" }]; + const run = detail.run || {}; + const summary = detail.summary || {}; + const artifacts = detail.artifacts || {}; + const counts = run.severityCounts || {}; + const rows = [ + { key: "run", label: "运行", value: shortId(run.id || run.runId || detail.runId) }, + { key: "scenario", label: "场景", value: run.scenarioId || run.scenario_id || "-" }, + { key: "status", label: "状态", value: run.status || "-" }, + { key: "checks", label: "监测项", value: String(run.findingCount ?? run.finding_count ?? 0) }, + { key: "error", label: "错误", value: String(redCount({ severityCounts: counts })) }, + { key: "warning", label: "警告", value: String(warningCount({ severityCounts: counts })) }, + { key: "observer", label: "Observer", value: run.observerId || run.observer_id || "-" }, + { key: "updated", label: "更新时间", value: formatAbsoluteDate(run.updatedAt || run.updated_at || run.createdAt || run.created_at) }, + { key: "report", label: "报告", value: shortHash(artifacts.reportJsonSha256 || run.reportJsonSha256 || run.report_json_sha256 || "") || "-" }, + ]; + const windowStart = summary.analysisWindow?.start || summary.window?.start; + const windowEnd = summary.analysisWindow?.end || summary.window?.end; + if (windowStart || windowEnd) rows.push({ key: "window", label: "窗口", value: `${formatDate(windowStart)} - ${formatDate(windowEnd)}` }); + return rows; } function commandSummary(detail) { diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 07900feb..8c9dfeea 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -92,7 +92,7 @@ export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, op const plan = webProbeSentinelConfigPlan(spec, "status", sentinel.id); const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime)); const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios)); - const reportViews = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews)); + const reportViews = resolveReportViewsWithCheckCatalog(recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews))); const rawPublicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure)); const publicExposure = effectiveWebProbeSentinelPublicExposure(spec, sentinel.id, rawPublicExposure); const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd)); @@ -268,6 +268,15 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp return service; } +function resolveReportViewsWithCheckCatalog(reportViews: Record): Record { + const ref = stringOrNull(reportViews.checkCatalogRef); + if (ref === null) return reportViews; + return { + ...reportViews, + checkCatalog: recordTarget(readSentinelConfigRefTarget(ref)), + }; +} + export function startWebProbeSentinelHttpService(service: WebProbeSentinelService): { readonly url: string; readonly stop: () => void } { const server = Bun.serve({ hostname: service.config.listenHost, @@ -664,7 +673,7 @@ function runCounts(db: Database): Record { function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database, health: Record, maintenance: MaintenanceState): Record { const latestRow = db.query("SELECT * FROM runs ORDER BY updated_at DESC LIMIT 1").get() as Record | null; const latestRun = latestRow === null ? null : dashboardRunSummary(config, db, latestRow); - const severityCounts = globalSeverityCounts(db); + const severityCounts = globalSeverityCounts(config, db); const latestUpdatedAt = latestRow === null ? null : stringOrNull(latestRow.updated_at); const latestRunAgeSeconds = latestUpdatedAt === null ? null : ageSeconds(latestUpdatedAt); return { @@ -744,7 +753,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, const row = readRunRow(db, runId); if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true }; const stored = readMetadata(db, `run.report.${runId}`) ?? {}; - const findings = findingsForRun(db, runId, dashboardPageSize(config)); + const findings = findingsForRun(config, db, runId, dashboardPageSize(config)); const views = record(stored.views); return { ok: true, @@ -775,20 +784,24 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, const limit = dashboardPage(url, config).limit; const filters = dashboardFindingFilters(url); const where = findingWhereClause(filters); + const queryLimit = Math.min(dashboardMaxPageSize(config), Math.max(limit + 1, limit * 4)); const rows = db.query(` - SELECT f.finding_id, f.severity, r.scenario_id, SUM(f.count) AS count, COUNT(DISTINCT f.run_id) AS run_count, + SELECT f.finding_id, + (SELECT f2.severity FROM findings f2 JOIN runs r2 ON r2.id = f2.run_id WHERE f2.finding_id = f.finding_id AND r2.scenario_id = r.scenario_id ORDER BY f2.created_at DESC LIMIT 1) AS severity, + r.scenario_id, SUM(f.count) AS count, COUNT(DISTINCT f.run_id) AS run_count, MAX(f.created_at) AS latest_at, MAX(f.summary) AS summary FROM findings f JOIN runs r ON r.id = f.run_id ${where.sql} - GROUP BY f.finding_id, f.severity, r.scenario_id - ORDER BY ${severityRankSql("f.severity")} DESC, latest_at DESC + GROUP BY f.finding_id, r.scenario_id + ORDER BY latest_at DESC LIMIT ? - `).all(...where.params, limit) as Record[]; + `).all(...where.params, queryLimit) as Record[]; + const severityFilter = stringOrNull(filters.severity); const items = rows.map((row) => { const latestRun = latestRunForFinding(db, row); const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id)); - return { + return enrichFindingWithCheck(config, { code: stringOrNull(row.finding_id), findingId: stringOrNull(row.finding_id), severity: stringOrNull(row.severity), @@ -806,8 +819,10 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, evidenceSummary: stringOrNull(latestDetail?.evidenceSummary), traceability: latestRun === null ? null : runTraceability(config, latestRun), valuesRedacted: true, - }; - }); + }); + }).filter((item) => severityFilter === null || stringOrNull(item.checkLevel) === severityFilter || stringOrNull(item.severity) === severityFilter) + .sort(compareCheckFindingRows) + .slice(0, limit); return { ok: true, contractVersion: DASHBOARD_CONTRACT_VERSION, @@ -817,7 +832,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, filters, page: { limit, - hasMore: items.length === limit, + hasMore: rows.length === queryLimit, sort: "severity", direction: "desc", }, @@ -871,7 +886,7 @@ function dashboardReportView(config: WebProbeSentinelServiceConfig, db: Database function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database, row: Record): Record { const id = stringOrNull(row.id); - const severityCounts = id === null ? {} : severityCountsForRun(db, id); + const severityCounts = id === null ? {} : severityCountsForRun(config, db, id); const maxSeverity = maxSeverityFromCounts(severityCounts); return { id, @@ -989,11 +1004,6 @@ function runWhereClause(filters: Record): { readonly sql: strin function findingWhereClause(filters: Record): { readonly sql: string; readonly params: readonly (string | number)[] } { const clauses: string[] = []; const params: (string | number)[] = []; - const severity = stringOrNull(filters.severity); - if (severity !== null) { - clauses.push("f.severity = ?"); - params.push(severity); - } const code = stringOrNull(filters.code); if (code !== null) { clauses.push("f.finding_id = ?"); @@ -1052,15 +1062,15 @@ function readRunRow(db: Database, runId: string): Record | null return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record | null; } -function findingsForRun(db: Database, runId: string, limit: number): readonly Record[] { +function findingsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string, limit: number): readonly Record[] { const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT ?") .all(runId, limit) as Record[]; - return rows.map((row) => enrichFindingRowWithStoredDetail(db, runId, row)); + return rows.map((row) => enrichFindingRowWithStoredDetail(config, db, runId, row)); } -function enrichFindingRowWithStoredDetail(db: Database, runId: string, row: Record): Record { +function enrichFindingRowWithStoredDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string, row: Record): Record { const detail = storedFindingDetailForRow(db, row, runId); - return { + return enrichFindingWithCheck(config, { ...row, code: stringOrNull(row.finding_id), findingId: stringOrNull(row.finding_id), @@ -1070,7 +1080,7 @@ function enrichFindingRowWithStoredDetail(db: Database, runId: string, row: Reco nextAction: stringOrNull(detail?.nextAction), evidenceSummary: stringOrNull(detail?.evidenceSummary), valuesRedacted: true, - }; + }); } function storedFindingDetailForRow(db: Database, row: Record, runId: string | null): Record | null { @@ -1137,14 +1147,110 @@ function compactFindingEvidenceSummary(value: unknown): string | null { return text.length > 240 ? `${text.slice(0, 239)}…` : text; } -function globalSeverityCounts(db: Database): Record { - const rows = db.query("SELECT severity, SUM(count) AS count FROM findings GROUP BY severity").all() as { severity: string; count: number }[]; - return Object.fromEntries(rows.map((row) => [row.severity, Number(row.count)])); +function enrichFindingWithCheck(config: WebProbeSentinelServiceConfig, value: Record): Record { + const rawSeverity = stringOrNull(value.severity) ?? stringOrNull(value.level); + const check = checkForFinding(config, value); + const checkLevel = stringOrNull(check.level) ?? normalizeCheckLevel(rawSeverity) ?? "unknown"; + return { + ...value, + rawSeverity, + severity: checkLevel, + level: checkLevel, + check, + checkId: stringOrNull(check.id), + checkCode: stringOrNull(check.code), + checkLevel, + checkTitleZh: stringOrNull(check.titleZh), + checkSummaryZh: stringOrNull(check.summaryZh), + checkRegistered: check.registered === true, + blocking: value.blocking === true || check.blocking === true, + valuesRedacted: true, + }; } -function severityCountsForRun(db: Database, runId: string): Record { - const rows = db.query("SELECT severity, SUM(count) AS count FROM findings WHERE run_id = ? GROUP BY severity").all(runId) as { severity: string; count: number }[]; - return Object.fromEntries(rows.map((row) => [row.severity, Number(row.count)])); +function checkForFinding(config: WebProbeSentinelServiceConfig, value: Record): Record { + const findingId = stringOrNull(value.finding_id) ?? stringOrNull(value.findingId) ?? stringOrNull(value.id) ?? stringOrNull(value.kind) ?? stringOrNull(value.code) ?? "unknown-check"; + const entry = checkCatalogById(config).get(findingId) ?? null; + const rawLevel = normalizeCheckLevel(stringOrNull(value.severity) ?? stringOrNull(value.level)); + if (entry === null) { + const summary = stringOrNull(value.summary) ?? stringOrNull(value.message) ?? findingId; + return { + id: findingId, + code: null, + level: rawLevel ?? "unknown", + titleZh: findingId, + summaryZh: summary.slice(0, 160), + blocking: value.blocking === true, + order: 10000, + registered: false, + valuesRedacted: true, + }; + } + return { + id: stringOrNull(entry.id) ?? findingId, + code: stringOrNull(entry.code), + level: normalizeCheckLevel(stringOrNull(entry.level)) ?? rawLevel ?? "unknown", + titleZh: stringOrNull(entry.titleZh) ?? findingId, + summaryZh: stringOrNull(entry.summaryZh) ?? stringOrNull(value.summary) ?? stringOrNull(value.message) ?? findingId, + blocking: entry.blocking === true, + order: numberOr(entry.order, 10000), + registered: true, + valuesRedacted: true, + }; +} + +function checkCatalogById(config: WebProbeSentinelServiceConfig): Map> { + const catalog = record(config.reportViews.checkCatalog); + const items = arrayRecords(catalog.items); + const result = new Map>(); + for (const item of items) { + const id = stringOrNull(item.id); + if (id !== null) result.set(id, item); + const aliases = Array.isArray(item.aliases) ? item.aliases : []; + for (const alias of aliases) { + if (typeof alias === "string" && alias.length > 0) result.set(alias, item); + } + } + return result; +} + +function checkLevelCounts(config: WebProbeSentinelServiceConfig, rows: readonly Record[]): Record { + const counts: Record = {}; + for (const row of rows) { + const check = checkForFinding(config, row); + const level = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(row.severity)) ?? "unknown"; + counts[level] = (counts[level] ?? 0) + numberOr(row.count, 1); + } + return counts; +} + +function compareCheckFindingRows(left: Record, right: Record): number { + const severity = severityRank(stringOrNull(right.checkLevel) ?? stringOrNull(right.severity)) - severityRank(stringOrNull(left.checkLevel) ?? stringOrNull(left.severity)); + if (severity !== 0) return severity; + const order = numberOr(record(left.check).order, 10000) - numberOr(record(right.check).order, 10000); + if (order !== 0) return order; + const rightTime = Date.parse(stringOrNull(right.latestAt) ?? ""); + const leftTime = Date.parse(stringOrNull(left.latestAt) ?? ""); + return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0); +} + +function normalizeCheckLevel(value: string | null): string | null { + const normalized = (value ?? "").toLowerCase(); + if (["critical", "fatal"].includes(normalized)) return "critical"; + if (["red", "error", "failed", "blocked"].includes(normalized)) return "error"; + if (["warning", "warn", "amber", "yellow"].includes(normalized)) return "warning"; + if (["info", "notice"].includes(normalized)) return "info"; + return null; +} + +function globalSeverityCounts(config: WebProbeSentinelServiceConfig, db: Database): Record { + const rows = db.query("SELECT finding_id, severity, count FROM findings").all() as Record[]; + return checkLevelCounts(config, rows); +} + +function severityCountsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record { + const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ?").all(runId) as Record[]; + return checkLevelCounts(config, rows); } function latestRunForFinding(db: Database, row: Record): Record | null { @@ -1152,10 +1258,10 @@ function latestRunForFinding(db: Database, row: Record): Record SELECT r.* FROM findings f JOIN runs r ON r.id = f.run_id - WHERE f.finding_id = ? AND f.severity = ? AND r.scenario_id = ? + WHERE f.finding_id = ? AND r.scenario_id = ? ORDER BY f.created_at DESC LIMIT 1 - `).get(stringOrNull(row.finding_id), stringOrNull(row.severity), stringOrNull(row.scenario_id)) as Record | null; + `).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id)) as Record | null; } function runTraceability(config: WebProbeSentinelServiceConfig, row: Record): Record { @@ -1323,7 +1429,8 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in db.query("DELETE FROM findings WHERE run_id = ?").run(runId); for (const item of findings) { const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding"; - const severity = stringOrNull(item.severity) ?? stringOrNull(item.level) ?? "unknown"; + const check = checkForFinding(config, { ...item, id: findingId }); + const severity = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(item.severity) ?? stringOrNull(item.level)) ?? "unknown"; const summary = stringOrNull(item.summary) ?? stringOrNull(item.message) ?? findingId; db.query("INSERT INTO findings (run_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)") .run(runId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now); @@ -1357,7 +1464,7 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view const selectedRunId = stringOrNull(row.id); if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true }; const stored = readMetadata(db, `run.report.${selectedRunId}`) ?? {}; - const findings = findingsForRun(db, selectedRunId, 50); + const findings = findingsForRun(config, db, selectedRunId, 50); const views = record(stored.views); const storedView = record(views[view]); const renderedText = typeof storedView.renderedText === "string" ? storedView.renderedText : view === "summary" ? renderStoredSummary(row, stored, findings) : view === "findings" ? renderStoredFindings(row, findings) : null; @@ -1376,12 +1483,16 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view } function formatStoredFindingLine(item: Record): string { + const check = record(item.check); + const code = stringOrNull(item.checkCode) ?? stringOrNull(check.code) ?? stringOrNull(item.finding_id) ?? stringOrNull(item.findingId) ?? stringOrNull(item.code) ?? "-"; + const title = stringOrNull(item.checkTitleZh) ?? stringOrNull(check.titleZh) ?? stringOrNull(item.summary) ?? ""; + const summary = stringOrNull(item.checkSummaryZh) ?? stringOrNull(check.summaryZh) ?? stringOrNull(item.summary) ?? ""; const rootCause = stringOrNull(item.rootCause); const status = stringOrNull(item.rootCauseStatus); const nextAction = stringOrNull(item.nextAction); const evidence = stringOrNull(item.evidenceSummary); return [ - `${item.severity ?? "-"} ${item.finding_id ?? item.findingId ?? item.code ?? "-"} count=${item.count ?? "-"} ${item.summary ?? ""}`, + `${item.severity ?? "-"} ${code} ${title} count=${item.count ?? "-"} ${summary}`, rootCause === null ? null : `rootCause=${rootCause}${status === null ? "" : ` status=${status}`}`, evidence === null ? null : `evidence=${evidence}`, nextAction === null ? null : `next=${nextAction}`, @@ -1397,7 +1508,7 @@ function renderStoredSummary(row: Record, stored: Record, findings: readonly R ].join("\n"); } +function formatStoredAnalysisWindow(value: unknown): string { + const window = record(value); + const fields = [ + ["start", stringOrNull(window.start)], + ["end", stringOrNull(window.end)], + ["samples", numberOr(window.sampleCount, numberOr(window.samples, -1)) >= 0 ? String(numberOr(window.sampleCount, numberOr(window.samples, 0))) : null], + ["durationMs", numberOr(window.durationMs, -1) >= 0 ? String(numberOr(window.durationMs, 0)) : null], + ].filter((entry): entry is [string, string] => entry[1] !== null); + return fields.length === 0 ? "-" : fields.map(([key, item]) => `${key}=${item}`).join(" "); +} + function thisMaintenanceFlag(input: Record): number { return input.maintenance === true ? 1 : 0; }