diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css index 36d27f4e..5e9dd415 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -1084,6 +1084,24 @@ select { overflow-wrap: anywhere; } +.finding-root-cause { + display: grid; + gap: 3px; + padding: 7px 8px; + border-left: 3px solid #2563eb; + border-radius: 4px; + background: #f5f9ff; + color: #1f2937; + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.finding-root-cause strong { + color: #1d4ed8; + font-size: 11px; +} + .finding-actions { display: flex; flex-wrap: wrap; diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index 68f93f75..3340738a 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -523,6 +523,9 @@ function renderFindingItemCollapsed(item) { const latestRunId = item.latestRunId || "-"; const hasLatestRun = latestRunId !== "-"; const codeLabel = displayFindingCode(code); + const rootCause = findingRootCauseText(item); + const evidence = findingEvidenceText(item); + const nextAction = item.nextAction || findingNextAction(code); return ``; @@ -732,12 +737,13 @@ function detailFindings(findings) { } return `
运行发现项 · ${formatNumber(findings.length)} 条
- + ${findings.map((item) => ` + `).join("")}
严重级别代码次数摘要报告严重级别代码次数摘要根因报告
${escapeHtml(displaySeverity(item.severity))} ${escapeHtml(item.finding_id || item.findingId || "-")} ${escapeHtml(String(item.count ?? 0))} ${escapeHtml(shortText(displayFindingSummary(item.finding_id || item.findingId || "", item.summary || ""), 220))}${escapeHtml(shortText(findingRootCauseText(item) || item.nextAction || "-", 240))} ${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}
`; @@ -1252,6 +1258,10 @@ function displayFindingCode(code) { const normalized = String(code || "").toLowerCase(); const labels = { "quick-verify-no-business-turn": "quick verify 未触达业务 turn", + "session-rail-title-fallback-root-cause": "INV-02 会话标题 fallback 根因", + "trace-events-page-read-404-root-cause": "INV-07 trace events 404 根因", + "trace-events-page-read-http-error-root-cause": "trace events HTTP 错误根因", + "trace-events-page-read-requestfailed-root-cause": "trace events 网络失败根因", "observer-command-failed": "观察器控制命令失败", "runtime-requestfailed": "运行时请求失败", "runtime-console-alerts": "运行时控制台告警", @@ -1267,6 +1277,10 @@ function displayFindingSummary(code, summary) { const normalized = String(code || "").toLowerCase(); const summaries = { "quick-verify-no-business-turn": "quick verify 没有形成 sendPrompt、session、trace rows 或 Final Response;不能把公开 dashboard 200 当作 HWLAB 恢复证据。", + "session-rail-title-fallback-root-cause": "会话列表可见 Session ses_* fallback 标题;根因定位到 session list projection/read model 或 rail 数据绑定缺稳定 title/preview。", + "trace-events-page-read-404-root-cause": "trace events page read 返回 404;根因定位到 /v1/workbench/traces/:traceId/events API 分页/read-model 合约,早于 DOM 渲染。", + "trace-events-page-read-http-error-root-cause": "trace events page read 返回 HTTP 错误;根因定位到 trace-events API 路径,不是通用前端渲染失败。", + "trace-events-page-read-requestfailed-root-cause": "trace events page read 在浏览器网络层失败;monitor 已定位路径,但需要 OTel/API 字段确认后端根因。", "observer-command-failed": "observe control command 失败,需要查看 observer timeline 和 failed command 文件确认是 readiness、session API、超时还是 runner shutdown。", "runtime-requestfailed": "页面运行时存在请求失败,需要按路径聚合确认是 asset/provenance 噪声、public origin、auth/session 还是 Workbench API。", "runtime-console-alerts": "页面控制台出现告警,需要结合 run detail 的 network/console 证据判断是否影响业务 turn。", @@ -1284,6 +1298,10 @@ function findingNextAction(code) { const normalized = String(code || "").toLowerCase(); const actions = { "quick-verify-no-business-turn": "下一步: 打开该 run 的 turn-summary/trace-frame,并用 CLI 对照命令确认没有业务 turn。", + "session-rail-title-fallback-root-cause": "下一步: 查同 run 的 OTel session_list_read fallbackTitleCount/fallbackTitleRatio,修 session list projection/read model title/preview。", + "trace-events-page-read-404-root-cause": "下一步: 查同 trace 的 OTel trace_events_read sinceSeq/range/hasMore/fullTraceLoaded,修 trace-events 分页合约或 instrumentation。", + "trace-events-page-read-http-error-root-cause": "下一步: 按 traceId/afterProjectedSeq 对齐 OTel trace_events_read 和 HTTP route span。", + "trace-events-page-read-requestfailed-root-cause": "下一步: 先排除 observer refresh/navigation;若非刷新噪声,补 HTTP route span 的 traceId/status/afterProjectedSeq 字段。", "observer-command-failed": "下一步: 查看 observe collect timeline 和 failed command 文件,定位失败阶段。", "runtime-requestfailed": "下一步: 按请求路径聚合失败,区分网络、auth/session、API 或静态资源问题。", "runtime-console-alerts": "下一步: 结合 console 样本与业务 trace 判断是否为阻塞级。", @@ -1294,6 +1312,23 @@ function findingNextAction(code) { return actions[normalized] || "下一步: 打开最近运行详情,并用 CLI 对照命令复核同一 run/observer/report。"; } +function findingRootCauseText(item) { + const rootCause = item.rootCause || ""; + const status = item.rootCauseStatus || ""; + const confidence = item.rootCauseConfidence || ""; + if (!rootCause && !status && !confidence) return ""; + return [ + rootCause ? `rootCause=${rootCause}` : "", + status ? `status=${status}` : "", + confidence ? `confidence=${confidence}` : "", + ].filter(Boolean).join(" · "); +} + +function findingEvidenceText(item) { + const text = item.evidenceSummary || ""; + return text ? shortText(String(text), 220) : ""; +} + function formatSeveritySummary(counts) { const entries = Object.entries(counts || {}).filter(([, value]) => Number(value || 0) > 0); if (entries.length === 0) return "无"; diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 1a51810b..ccb49546 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -1810,6 +1810,115 @@ function groupApiDomLagCandidates(candidates) { .sort((a, b) => Number(b.maxDomChangeDeltaMs ?? -1) - Number(a.maxDomChangeDeltaMs ?? -1) || b.count - a.count || String(a.path).localeCompare(String(b.path))); } +function detectTraceEventsPageReadIssues(network) { + const events = (Array.isArray(network) ? network : []) + .filter((item) => item?.observerInitiated !== true && (item?.type === "response" || item?.type === "requestfailed")) + .map(compactTraceEventsPageReadEvent) + .filter((item) => item !== null); + const http404 = events.filter((item) => item.type === "response" && Number(item.status) === 404); + const httpErrors = events.filter((item) => item.type === "response" && Number(item.status) >= 400 && Number(item.status) !== 404); + const requestFailed = events.filter((item) => item.type === "requestfailed"); + return { + events, + http404, + httpErrors, + requestFailed, + summary: traceEventsPageReadIssueSummary(events), + valuesRedacted: true + }; +} + +function compactTraceEventsPageReadEvent(item) { + const parsed = parseTraceEventsPageReadUrl(item?.url); + if (!parsed.match) return null; + const failureText = item?.failureKind ?? item?.failure ?? item?.errorText ?? null; + return { + ts: item?.ts ?? null, + pageRole: item?.pageRole ?? null, + pageId: item?.pageId ?? null, + commandId: item?.commandId ?? null, + method: String(item?.method || "GET").toUpperCase(), + type: item?.type ?? null, + status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null, + path: "/v1/workbench/traces/:traceId/events", + rawPath: parsed.rawPath, + traceId: parsed.traceId, + afterProjectedSeq: parsed.afterProjectedSeq, + sinceSeq: parsed.sinceSeq, + limit: parsed.limit, + tail: parsed.tail, + queryKeys: parsed.queryKeys, + failureKind: failureText ? limitText(String(failureText), 120) : null, + urlHash: item?.url ? sha256(item.url) : null, + valuesRedacted: true + }; +} + +function parseTraceEventsPageReadUrl(value) { + const fallback = { + match: false, + rawPath: urlPath(value), + traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u), + afterProjectedSeq: null, + sinceSeq: null, + limit: null, + tail: null, + queryKeys: [], + }; + try { + const parsed = new URL(String(value || ""), "http://invalid.local/"); + const rawPath = parsed.pathname || "-"; + const match = rawPath.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); + return { + match: Boolean(match), + rawPath, + traceId: match ? decodeURIComponent(match[1]) : fallback.traceId, + afterProjectedSeq: numericSearchParam(parsed.searchParams, "afterProjectedSeq"), + sinceSeq: numericSearchParam(parsed.searchParams, "sinceSeq") ?? numericSearchParam(parsed.searchParams, "afterSeq"), + limit: numericSearchParam(parsed.searchParams, "limit"), + tail: numericSearchParam(parsed.searchParams, "tail"), + queryKeys: Array.from(parsed.searchParams.keys()).sort().slice(0, 12), + }; + } catch { + return fallback; + } +} + +function numericSearchParam(searchParams, key) { + const raw = searchParams?.get?.(key); + if (raw === null || raw === undefined || raw === "") return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +function traceEventsPageReadIssueSummary(events) { + const items = Array.isArray(events) ? events : []; + const statuses = uniqueSorted(items.map((item) => item.status).filter((item) => item !== null && item !== undefined).map(String)); + const traceIds = uniqueSorted(items.map((item) => item.traceId).filter(Boolean).map(String)).slice(0, 8); + const afterProjectedSeqs = uniqueSorted(items.map((item) => item.afterProjectedSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8); + const sinceSeqs = uniqueSorted(items.map((item) => item.sinceSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8); + const failureKinds = uniqueSorted(items.map((item) => item.failureKind).filter(Boolean).map(String)).slice(0, 6); + return { + eventCount: items.length, + responseErrorCount: items.filter((item) => item.type === "response" && Number(item.status) >= 400).length, + http404Count: items.filter((item) => item.type === "response" && Number(item.status) === 404).length, + requestFailedCount: items.filter((item) => item.type === "requestfailed").length, + statuses, + traceIds, + afterProjectedSeqs, + sinceSeqs, + failureKinds, + firstAt: items.reduce((value, item) => minIso(value, item.ts ?? null), null), + lastAt: items.reduce((value, item) => maxIso(value, item.ts ?? null), null), + rootCauseVisibility: "browser network rows identify trace-events page-read path; OTel trace_events_read should confirm backend paging fields", + valuesRedacted: true + }; +} + +function uniqueSorted(values) { + return Array.from(new Set((values || []).filter((item) => item !== null && item !== undefined).map(String))).sort(); +} + function compactApiDomLagForOutput(report) { if (!report || typeof report !== "object") return null; return { @@ -1954,7 +2063,70 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN if (Number(loadingSummary.longestContinuousSeconds ?? 0) > visibleLoadingSlowSeconds) findings.push({ id: "page-loading-visible-over-budget", severity: "red", summary: "visible 加载中 stayed on screen longer than configured YAML budget; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overBudgetSegmentCount ?? loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, budgetSeconds: visibleLoadingSlowSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) }); if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) }); const sessionRailTitleSummary = sampleMetrics?.sessionRailTitles?.summary || {}; - if (Number(sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ id: "session-rail-title-fallback-over-threshold", severity: "red", summary: "visible session list rows exceeded configured YAML fallback-title ratio", count: sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount, thresholdRatio: alertThresholds.sessionRailFallbackRatio, maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), examples: sampleMetrics.sessionRailTitles.examples.slice(0, 20) }); + if (Number(sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ + id: "session-rail-title-fallback-root-cause", + severity: "red", + summary: "INV-02 root cause visible: session rail is rendering fallback Session ses_* titles, so list projection/read model or rail binding is missing stable title/preview data before DOM render", + rootCause: "session-list-title-projection-missing-or-not-bound", + rootCauseStatus: "confirmed-from-dom-session-rail", + rootCauseConfidence: "high", + nextAction: "Check OTel session_list_read fallbackTitleCount/fallbackTitleRatio for the same run; fix session list projection/read model title/firstUserMessagePreview before changing DOM fallback text.", + count: sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount, + thresholdRatio: alertThresholds.sessionRailFallbackRatio, + maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, + maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, + evidence: { + thresholdRatio: alertThresholds.sessionRailFallbackRatio, + maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, + maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, + overThresholdSampleCount: sessionRailTitleSummary.overThresholdSampleCount ?? null, + majorityFallbackSampleCount: sessionRailTitleSummary.majorityFallbackSampleCount ?? null, + valuesRedacted: true + }, + samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), + examples: sampleMetrics.sessionRailTitles.examples.slice(0, 20), + valuesRedacted: true + }); + const traceEventsPageReadIssues = detectTraceEventsPageReadIssues(network); + if (traceEventsPageReadIssues.http404.length > 0) findings.push({ + id: "trace-events-page-read-404-root-cause", + severity: "red", + summary: "INV-07 root cause visible: /v1/workbench/traces/:traceId/events returned HTTP 404 for a trace event page read, so the failure is in the trace-events API paging/read-model contract before DOM rendering", + rootCause: "trace-events-api-page-read-returned-404", + rootCauseStatus: "confirmed-from-browser-network", + rootCauseConfidence: "high", + nextAction: "Use OTel trace_events_read for the same trace to compare sinceSeq/afterProjectedSeq, returnedEvents, range, totalEvents, hasMore and fullTraceLoaded; fix backend paging contract or add missing instrumentation before changing renderer behavior.", + count: traceEventsPageReadIssues.http404.length, + evidence: traceEventsPageReadIssues.summary, + samples: traceEventsPageReadIssues.http404.slice(0, 20), + valuesRedacted: true + }); + if (traceEventsPageReadIssues.httpErrors.length > 0) findings.push({ + id: "trace-events-page-read-http-error-root-cause", + severity: "red", + summary: "trace events page read returned HTTP error status; monitor can localize this to the trace-events API instead of a generic Workbench render failure", + rootCause: "trace-events-api-page-read-http-error", + rootCauseStatus: "confirmed-from-browser-network", + rootCauseConfidence: "high", + nextAction: "Drill down by traceId and afterProjectedSeq, then compare with OTel trace_events_read paging fields.", + count: traceEventsPageReadIssues.httpErrors.length, + evidence: traceEventsPageReadIssues.summary, + samples: traceEventsPageReadIssues.httpErrors.slice(0, 20), + valuesRedacted: true + }); + if (traceEventsPageReadIssues.requestFailed.length > 0) findings.push({ + id: "trace-events-page-read-requestfailed-root-cause", + severity: "amber", + summary: "trace events page read failed at browser/network level; monitor localized the failure path, but HTTP status is unavailable so OTel/API instrumentation is needed to confirm the backend root cause", + rootCause: "trace-events-api-page-read-network-failed", + rootCauseStatus: "network-signal-needs-otel-confirmation", + rootCauseConfidence: "medium", + nextAction: "Check whether this happened during observer refresh/navigation; if not, query OTel by /v1/workbench/traces/:traceId/events and add route span fields when status/afterProjectedSeq are missing.", + count: traceEventsPageReadIssues.requestFailed.length, + evidence: traceEventsPageReadIssues.summary, + samples: traceEventsPageReadIssues.requestFailed.slice(0, 20), + valuesRedacted: true + }); if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) }); if ((runtimeAlerts?.summary?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) }); if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) }); diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index c3855a54..9ca4111c 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -355,10 +355,11 @@ function renderWebObserveCollectTable(value: Record): string { if (jsonFindings.length > 0) { lines.push( "Findings:", - webObserveTable(["KIND", "SEVERITY", "COUNT", "SUMMARY"], jsonFindings.map((item) => [ + webObserveTable(["KIND", "SEVERITY", "COUNT", "ROOT_CAUSE", "SUMMARY"], jsonFindings.map((item) => [ webObserveShort(webObserveText(item.kind ?? item.id ?? item.code), 48), item.severity ?? item.level, item.count ?? item.sampleCount, + webObserveShort(webObserveText(item.rootCause ?? item.rootCauseStatus), 48), webObserveShort(webObserveText(item.summary ?? item.message), 96), ])), "", @@ -550,12 +551,13 @@ function renderWebObserveProjectCollectTable(value: Record, col ]) : [["-", "-", "-", "-", "-", "-", "-"]]), "", "Findings:", - webObserveTable(["SEVERITY", "ID", "COUNT", "SUMMARY"], findings.length > 0 ? findings.map((item) => [ + webObserveTable(["SEVERITY", "ID", "COUNT", "ROOT_CAUSE", "SUMMARY"], findings.length > 0 ? findings.map((item) => [ webObserveShort(webObserveText(item.severity ?? item.level), 12), webObserveShort(webObserveText(item.id ?? item.kind ?? item.code), 48), item.count ?? item.sampleCount, + webObserveShort(webObserveText(item.rootCause ?? item.rootCauseStatus), 48), webObserveShort(webObserveText(item.summary ?? item.message), 96), - ]) : [["-", "-", "-", "-"]]), + ]) : [["-", "-", "-", "-", "-"]]), "", "Disclosure:", " collect project views are rendered from existing samples/control/analysis artifacts; they do not create a new sampling source.", diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index e6bc61c5..2121d45a 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -2633,11 +2633,41 @@ function compactQuickVerifyRecordFinding(value: unknown): Record = {}; + for (const key of keys) { + const raw = item[key]; + if (raw === null || raw === undefined) continue; + compact[key] = Array.isArray(raw) ? raw.slice(0, 6) : raw; + } + return Object.keys(compact).length === 0 ? null : boundQuickVerifyRecordText(JSON.stringify(compact), 240); +} + function compactQuickVerifyRecordStep(value: unknown): Record { const item = record(value); return { @@ -3230,7 +3260,7 @@ function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: st "let artifactCount=0; let screenshot=null;", "function walk(dir){let entries=[]; try{entries=fs.readdirSync(dir,{withFileTypes:true})}catch{return}; for(const e of entries){const p=path.join(dir,e.name); if(e.isDirectory()) walk(p); else { artifactCount++; if(/\\.png$/i.test(e.name)){const b=read(p); screenshot={path:p,sha256:sha(b),bytes:b?b.length:0}; } } }}", "walk(stateDir);", - "const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});", + "const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});", "const slow=arr(report?.pagePerformanceSlowApi ?? report?.archivePagePerformanceSlowApi).slice(0,8).map((item)=>{const v=rec(item); return {path:clip(v.path??v.route,120),sampleCount:v.sampleCount??null,p95Ms:v.p95Ms??null,maxMs:v.maxMs??null,overFiveSecondCount:v.overFiveSecondCount??null};});", "console.log(JSON.stringify({ok:!!report,reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,valuesRedacted:true}));", "NODE", diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 67ba9dc0..1c4b5c7f 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -650,6 +650,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, `).all(...where.params, limit) as Record[]; const items = rows.map((row) => { const latestRun = latestRunForFinding(db, row); + const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id)); return { code: stringOrNull(row.finding_id), findingId: stringOrNull(row.finding_id), @@ -661,6 +662,11 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, latestRunId: latestRun === null ? null : stringOrNull(latestRun.id), latestReportJsonSha256: latestRun === null ? null : stringOrNull(latestRun.report_json_sha256), summary: stringOrNull(row.summary), + rootCause: stringOrNull(latestDetail?.rootCause), + rootCauseStatus: stringOrNull(latestDetail?.rootCauseStatus), + rootCauseConfidence: stringOrNull(latestDetail?.rootCauseConfidence), + nextAction: stringOrNull(latestDetail?.nextAction), + evidenceSummary: stringOrNull(latestDetail?.evidenceSummary), traceability: latestRun === null ? null : runTraceability(config, latestRun), valuesRedacted: true, }; @@ -910,8 +916,88 @@ function readRunRow(db: Database, runId: string): Record | null } function findingsForRun(db: Database, runId: string, limit: number): readonly Record[] { - return db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT ?") + 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)); +} + +function enrichFindingRowWithStoredDetail(db: Database, runId: string, row: Record): Record { + const detail = storedFindingDetailForRow(db, row, runId); + return { + ...row, + code: stringOrNull(row.finding_id), + findingId: stringOrNull(row.finding_id), + rootCause: stringOrNull(detail?.rootCause), + rootCauseStatus: stringOrNull(detail?.rootCauseStatus), + rootCauseConfidence: stringOrNull(detail?.rootCauseConfidence), + nextAction: stringOrNull(detail?.nextAction), + evidenceSummary: stringOrNull(detail?.evidenceSummary), + valuesRedacted: true, + }; +} + +function storedFindingDetailForRow(db: Database, row: Record, runId: string | null): Record | null { + if (runId === null) return null; + const stored = readMetadata(db, `run.report.${runId}`) ?? {}; + const details = arrayRecords(stored.findings); + if (details.length === 0) return null; + const findingId = stringOrNull(row.finding_id) ?? stringOrNull(row.findingId) ?? stringOrNull(row.code); + const severity = stringOrNull(row.severity); + return details.find((item) => { + const itemId = stringOrNull(item.finding_id) ?? stringOrNull(item.findingId) ?? stringOrNull(item.id) ?? stringOrNull(item.code); + if (itemId !== findingId) return false; + const itemSeverity = stringOrNull(item.severity) ?? stringOrNull(item.level); + return severity === null || itemSeverity === null || itemSeverity === severity; + }) ?? null; +} + +function compactStoredFinding(value: unknown): Record { + const item = record(value); + const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding"; + const severity = stringOrNull(item.severity) ?? stringOrNull(item.level) ?? "unknown"; + const summary = stringOrNull(item.summary) ?? stringOrNull(item.message) ?? findingId; + return { + id: findingId, + finding_id: findingId, + findingId, + code: findingId, + severity, + count: numberOr(item.count, numberOr(item.sampleCount, 1)), + summary: summary.slice(0, 500), + rootCause: stringOrNull(item.rootCause), + rootCauseStatus: stringOrNull(item.rootCauseStatus), + rootCauseConfidence: stringOrNull(item.rootCauseConfidence), + nextAction: stringOrNull(item.nextAction), + evidenceSummary: stringOrNull(item.evidenceSummary) ?? compactFindingEvidenceSummary(item.evidence), + valuesRedacted: true, + }; +} + +function compactFindingEvidenceSummary(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value.slice(0, 240); + const item = record(value); + const keys = [ + "http404Count", + "responseErrorCount", + "requestFailedCount", + "statuses", + "afterProjectedSeqs", + "sinceSeqs", + "traceIds", + "maxFallbackRatio", + "maxFallbackTitleCount", + "overThresholdSampleCount", + "majorityFallbackSampleCount", + ]; + const compact: Record = {}; + for (const key of keys) { + const raw = item[key]; + if (raw === null || raw === undefined) continue; + compact[key] = Array.isArray(raw) ? raw.slice(0, 6) : raw; + } + const text = Object.keys(compact).length > 0 ? JSON.stringify(compact) : JSON.stringify(item).slice(0, 240); + return text.length > 240 ? `${text.slice(0, 239)}…` : text; } function globalSeverityCounts(db: Database): Record { @@ -1111,6 +1197,7 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in reportJsonSha256, summary: record(input.summary), views: record(input.views), + findings: findings.map(compactStoredFinding), publicOrigin: stringOrNull(input.publicOrigin), screenshot: record(input.screenshot), artifactCount, @@ -1131,8 +1218,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 = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT 50") - .all(selectedRunId) as Record[]; + const findings = findingsForRun(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; @@ -1150,6 +1236,19 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view }; } +function formatStoredFindingLine(item: Record): string { + 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 ?? ""}`, + rootCause === null ? null : `rootCause=${rootCause}${status === null ? "" : ` status=${status}`}`, + evidence === null ? null : `evidence=${evidence}`, + nextAction === null ? null : `next=${nextAction}`, + ].filter((part) => part !== null).join(" | "); +} + function renderStoredSummary(row: Record, stored: Record, findings: readonly Record[]): string { const summary = record(stored.summary); return [ @@ -1162,7 +1261,7 @@ function renderStoredSummary(row: Record, stored: Record `${item.severity ?? "-"} ${item.finding_id ?? "-"} count=${item.count ?? "-"} ${item.summary ?? ""}`).join("\n"), + findings.length === 0 ? "-" : findings.slice(0, 12).map(formatStoredFindingLine).join("\n"), ].join("\n"); } @@ -1171,7 +1270,7 @@ function renderStoredFindings(row: Record, findings: readonly R "Web Probe Sentinel Findings", "=======================================================", `run=${stringOrNull(row.id) ?? "-"} report=${stringOrNull(row.report_json_sha256) ?? "-"}`, - findings.length === 0 ? "-" : findings.map((item) => `${item.severity ?? "-"} ${item.finding_id ?? "-"} count=${item.count ?? "-"} ${item.summary ?? ""}`).join("\n"), + findings.length === 0 ? "-" : findings.map(formatStoredFindingLine).join("\n"), ].join("\n"); } diff --git a/scripts/src/hwlab-node/web-observe-render.ts b/scripts/src/hwlab-node/web-observe-render.ts index 3fa27271..bccbee94 100644 --- a/scripts/src/hwlab-node/web-observe-render.ts +++ b/scripts/src/hwlab-node/web-observe-render.ts @@ -697,12 +697,13 @@ export function renderWebObserveAnalyzeTable(value: Record): st webObserveTable(consoleAlertGroups.length > 0 ? ["COUNT", "TYPE", "STATUS", "PATH", "LAST", "TRACES"] : ["TS", "TYPE", "STATUS", "PATH", "TRACE", "TEXT"], consoleAlertRows.length > 0 ? consoleAlertRows : [["-", "-", "-", "-", "-", "-"]]), "", "Findings:", - webObserveTable(["KIND", "SEVERITY", "COUNT", "SUMMARY"], findings.length > 0 ? findings.map((item) => [ - webObserveShort(webObserveText(item.kind ?? item.code), 32), + webObserveTable(["KIND", "SEVERITY", "COUNT", "ROOT_CAUSE", "SUMMARY"], findings.length > 0 ? findings.map((item) => [ + webObserveShort(webObserveText(item.kind ?? item.id ?? item.code), 32), webObserveText(item.severity ?? item.level), webObserveText(item.count ?? item.sampleCount), + webObserveShort(webObserveText(item.rootCause ?? item.rootCauseStatus), 48), webObserveShort(webObserveText(item.summary ?? item.message), 96), - ]) : [["-", "-", "-", "-"]]), + ]) : [["-", "-", "-", "-", "-"]]), "", "Archive red findings:", webObserveTable(["KIND", "COUNT", "SUMMARY"], archiveRedFindings.length > 0 ? archiveRedFindings.map((item) => [ diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 26832d04..5392e25c 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -195,11 +195,17 @@ const slimFinding=(item)=>{ if(!item||typeof item!=='object') return null; const samples=Array.isArray(item.samples)?item.samples.length:null; const groups=Array.isArray(item.groups)?item.groups.length:null; + const evidence=item.evidence&&typeof item.evidence==='object'?slimObject(item.evidence,10):null; return { kind:short(item.kind||item.code||item.id,80), severity:short(item.severity||item.level,24), count:item.count??item.sampleCount??null, summary:short(item.summary||item.message,240), + rootCause:short(item.rootCause,140), + rootCauseStatus:short(item.rootCauseStatus,90), + rootCauseConfidence:short(item.rootCauseConfidence,40), + nextAction:short(item.nextAction,240), + evidenceSummary:evidence?short(JSON.stringify(evidence),240):null, sampleRows:samples, groupRows:groups }; diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index f9278a6c..f6ae5658 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -1774,7 +1774,8 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption "const slimTurnColumn = (item) => { const v = objectOrNull(item) || {}; return { label: clip(v.label, 24), pageRole: clip(v.pageRole, 24), pageId: clip(v.pageId, 32), pageEpoch: v.pageEpoch ?? null, promptIndex: v.promptIndex ?? null, lastPromptIndex: v.lastPromptIndex ?? null, traceId: clip(v.traceId, 48), firstSeq: v.firstSeq ?? null, lastSeq: v.lastSeq ?? null, source: clip(v.source, 48) }; };", "const slimSlowSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, seq: v.seq ?? null, path: clip(v.path ?? v.rawPath, 96), initiatorType: clip(v.initiatorType, 24), durationMs: v.durationMs ?? null, requestToResponseStartMs: v.requestToResponseStartMs ?? v.streamOpenMs ?? null, responseTransferMs: v.responseTransferMs ?? null, nextHopProtocol: clip(v.nextHopProtocol, 24), timingStatus: clip(v.timingStatus, 16), serverTimingNames: Array.isArray(v.serverTimingNames) ? v.serverTimingNames.slice(0, 4).map((x) => clip(x, 32)) : [], otelTraceId: clip(v.otelTraceId, 32) }; };", "const slimSlowApi = (item) => { const v = objectOrNull(item) || {}; return { path: clip(v.path ?? v.route, 96), route: clip(v.route ?? v.path, 96), sampleCount: v.sampleCount ?? null, p95Ms: v.p95Ms ?? v.p95 ?? null, maxMs: v.maxMs ?? v.max ?? null, budgetMs: v.budgetMs ?? null, overBudgetCount: v.overBudgetCount ?? null, overFiveSecondCount: v.overFiveSecondCount ?? null, slowSamples: Array.isArray(v.slowSamples) ? v.slowSamples.slice(0, 3).map(slimSlowSample) : [] }; };", - "const slimFinding = (item) => { const v = objectOrNull(item) || {}; return { kind: clip(v.kind ?? v.id ?? v.code, 48), code: clip(v.code ?? v.id ?? v.kind, 48), severity: clip(v.severity ?? v.level, 24), level: clip(v.level ?? v.severity, 24), count: v.count ?? v.sampleCount ?? null, sampleCount: v.sampleCount ?? v.count ?? null, summary: clip(v.summary ?? v.message, 180), message: clip(v.message ?? v.summary, 180) }; };", + "const evidenceSummary = (value) => { const v = objectOrNull(value); if (!v) return null; const out = {}; for (const key of ['http404Count','responseErrorCount','requestFailedCount','statuses','afterProjectedSeqs','sinceSeqs','traceIds','maxFallbackRatio','maxFallbackTitleCount','overThresholdSampleCount','majorityFallbackSampleCount']) if (v[key] !== undefined && v[key] !== null) out[key] = Array.isArray(v[key]) ? v[key].slice(0,6) : v[key]; const text = Object.keys(out).length > 0 ? JSON.stringify(out) : null; return text ? clip(text, 220) : null; };", + "const slimFinding = (item) => { const v = objectOrNull(item) || {}; return { kind: clip(v.kind ?? v.id ?? v.code, 48), code: clip(v.code ?? v.id ?? v.kind, 48), severity: clip(v.severity ?? v.level, 24), level: clip(v.level ?? v.severity, 24), count: v.count ?? v.sampleCount ?? null, sampleCount: v.sampleCount ?? v.count ?? null, summary: clip(v.summary ?? v.message, 180), message: clip(v.message ?? v.summary, 180), rootCause: clip(v.rootCause, 120), rootCauseStatus: clip(v.rootCauseStatus, 80), rootCauseConfidence: clip(v.rootCauseConfidence, 40), nextAction: clip(v.nextAction, 220), evidenceSummary: evidenceSummary(v.evidence) ?? clip(v.evidenceSummary, 220) }; };", "const slimProjectManagement = (value) => { const v = objectOrNull(value); if (!v) return null; const s = objectOrNull(v.summary) || v; return { summary: { enabled: s.enabled === true, projectSampleCount: s.projectSampleCount ?? null, mdtodoSampleCount: s.mdtodoSampleCount ?? null, latestPageKind: clip(s.latestPageKind, 48), latestPath: clip(s.latestPath, 96), latestSourceCount: s.latestSourceCount ?? null, latestFileCount: s.latestFileCount ?? null, latestTaskCount: s.latestTaskCount ?? null, latestSelectedTaskRefHash: clip(s.latestSelectedTaskRefHash, 80), launchCommandCount: s.launchCommandCount ?? null, launchSuccessCount: s.launchSuccessCount ?? null, launchFailureCount: s.launchFailureCount ?? null, launchWithOtelTraceHeaderCount: s.launchWithOtelTraceHeaderCount ?? null, projectApiResponseCount: s.projectApiResponseCount ?? null, projectApiFailureCount: s.projectApiFailureCount ?? null, projectApiSlowPathCount: s.projectApiSlowPathCount ?? null, slowApiBudgetMs: s.slowApiBudgetMs ?? null }, commands: takeTail(v.commands, 8).map((item) => { const row = objectOrNull(item) || {}; return { ts: row.ts ?? null, phase: clip(row.phase, 16), type: clip(row.type, 32), commandId: clip(row.commandId, 80), launchStatus: row.launchStatus ?? null, sessionId: clip(row.sessionId, 80), workbenchUrl: clip(row.workbenchUrl, 120), otelTraceId: clip(row.otelTraceId, 32), selectedTaskRefHash: clip(row.selectedTaskRefHash, 80) }; }), samples: takeTail(v.samples, 8).map((item) => { const row = objectOrNull(item) || {}; return { seq: row.seq ?? null, ts: row.ts ?? null, pageRole: clip(row.pageRole, 24), path: clip(row.path, 96), pageKind: clip(row.pageKind, 48), sourceCount: row.sourceCount ?? null, fileCount: row.fileCount ?? null, taskCount: row.taskCount ?? null, selectedTaskRefHash: clip(row.selectedTaskRefHash, 80), launchButtonEnabled: row.launchButtonEnabled === true, workbenchLinkCount: row.workbenchLinkCount ?? null }; }), projectApiByPath: takeHead(v.projectApiByPath, 8).map(slimNetworkGroup), valuesRedacted: true }; };", "const slimDomGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, text: clip(v.text ?? v.preview, 180) }; };", "const slimNetworkGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, method: clip(v.method, 12), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, promptIndexes: Array.isArray(v.promptIndexes) ? v.promptIndexes.slice(0, 6) : [], failureKinds: Array.isArray(v.failureKinds) ? v.failureKinds.slice(0, 4).map((x) => clip(x, 48)) : [] }; };", @@ -2178,7 +2179,8 @@ export function recoverWebObserveAnalyzeFromArtifacts(options: NodeWebProbeObser "const arr = (value) => Array.isArray(value) ? value : [];", "const clip = (value, limit = 160) => value === null || value === undefined ? null : String(value).slice(0, limit);", "const numberish = (...values) => { for (const value of values) { const n = Number(value); if (Number.isFinite(n)) return value; } return null; };", - "const slimFinding = (item) => { const v = objectOrNull(item) || {}; return { kind: clip(v.kind ?? v.id ?? v.code, 48), code: clip(v.code ?? v.id ?? v.kind, 48), severity: clip(v.severity ?? v.level, 24), level: clip(v.level ?? v.severity, 24), count: v.count ?? v.sampleCount ?? null, sampleCount: v.sampleCount ?? v.count ?? null, summary: clip(v.summary ?? v.message, 180), message: clip(v.message ?? v.summary, 180) }; };", + "const evidenceSummary = (value) => { const v = objectOrNull(value); if (!v) return null; const out = {}; for (const key of ['http404Count','responseErrorCount','requestFailedCount','statuses','afterProjectedSeqs','sinceSeqs','traceIds','maxFallbackRatio','maxFallbackTitleCount','overThresholdSampleCount','majorityFallbackSampleCount']) if (v[key] !== undefined && v[key] !== null) out[key] = Array.isArray(v[key]) ? v[key].slice(0,6) : v[key]; const text = Object.keys(out).length > 0 ? JSON.stringify(out) : null; return text ? clip(text, 220) : null; };", + "const slimFinding = (item) => { const v = objectOrNull(item) || {}; return { kind: clip(v.kind ?? v.id ?? v.code, 48), code: clip(v.code ?? v.id ?? v.kind, 48), severity: clip(v.severity ?? v.level, 24), level: clip(v.level ?? v.severity, 24), count: v.count ?? v.sampleCount ?? null, sampleCount: v.sampleCount ?? v.count ?? null, summary: clip(v.summary ?? v.message, 180), message: clip(v.message ?? v.summary, 180), rootCause: clip(v.rootCause, 120), rootCauseStatus: clip(v.rootCauseStatus, 80), rootCauseConfidence: clip(v.rootCauseConfidence, 40), nextAction: clip(v.nextAction, 220), evidenceSummary: evidenceSummary(v.evidence) ?? clip(v.evidenceSummary, 220) }; };", "const slimRound = (item) => { const v = objectOrNull(item) || {}; return { promptIndex: v.promptIndex ?? null, promptTextHash: clip(v.promptTextHash, 80), sampleCount: v.sampleCount ?? null, firstSeq: v.firstSeq ?? null, lastSeq: v.lastSeq ?? null, lastTotalElapsedSeconds: v.lastTotalElapsedSeconds ?? null, lastRecentUpdateSeconds: v.lastRecentUpdateSeconds ?? null, loadingSamples: v.loadingSamples ?? null, maxLoadingCount: v.maxLoadingCount ?? null, loadingOwnerCount: v.loadingOwnerCount ?? null, diagnosticSamples: v.diagnosticSamples ?? null, terminalSamples: v.terminalSamples ?? null, finalTextSamples: v.finalTextSamples ?? null, turnTimingTotalElapsedZeroResetCount: v.turnTimingTotalElapsedZeroResetCount ?? null, turnTimingTotalElapsedForwardJumpCount: v.turnTimingTotalElapsedForwardJumpCount ?? null, turnTimingTotalElapsedForwardJumpMaxSeconds: v.turnTimingTotalElapsedForwardJumpMaxSeconds ?? null, turnTimingRecentUpdateJumpCount: v.turnTimingRecentUpdateJumpCount ?? null, turnTimingRecentUpdateMaxIncreaseSeconds: v.turnTimingRecentUpdateMaxIncreaseSeconds ?? null }; };", "const slimTurnColumn = (item) => { const v = objectOrNull(item) || {}; return { label: clip(v.label, 24), source: clip(v.source, 48), pageRole: clip(v.pageRole, 24), pageId: clip(v.pageId, 32), pageEpoch: v.pageEpoch ?? null, promptIndex: v.promptIndex ?? null, lastPromptIndex: v.lastPromptIndex ?? null, firstSeq: v.firstSeq ?? null, lastSeq: v.lastSeq ?? null, traceId: clip(v.traceId, 64), messageId: clip(v.messageId, 64) }; };", "const slimGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, method: clip(v.method, 12), status: v.status ?? null, path: clip(v.path ?? v.urlPath ?? v.route, 96), firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, text: clip(v.text ?? v.preview, 180), failureKinds: arr(v.failureKinds).slice(0, 4).map((x) => clip(x, 48)), traceIds: arr(v.traceIds).slice(0, 3).map((x) => clip(x, 64)) }; };",