From a8e28d80b1cc765798efe751e8169db95d5ab3dc Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:22:45 +0800 Subject: [PATCH] feat: add sentinel dashboard trace frame reader (#952) Co-authored-by: Codex --- .../dashboard.css | 102 ++++++++++++++++++ .../web-probe-sentinel-dashboard/dashboard.js | 97 +++++++++++++++-- 2 files changed, 193 insertions(+), 6 deletions(-) diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css index fb9bb910..34957b55 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -672,6 +672,104 @@ select { white-space: pre-wrap; } +.trace-reader-grid { + display: grid; + grid-template-columns: minmax(240px, 0.72fr) minmax(0, 1.28fr); + gap: 12px; +} + +.trace-turn-picker, +.trace-frame-view { + min-width: 0; +} + +.trace-choice-list { + display: grid; + gap: 8px; + margin-top: 8px; + max-height: 420px; + overflow: auto; +} + +.trace-choice { + display: grid; + gap: 4px; + width: 100%; + padding: 9px 10px; + border: 1px solid #d8e0ea; + border-radius: 8px; + background: #ffffff; + color: #344054; + cursor: pointer; + text-align: left; +} + +.trace-choice.active { + border-color: #95c7ff; + background: #edf6ff; +} + +.trace-choice span { + min-width: 0; + font-size: 12px; + font-weight: 800; + overflow-wrap: anywhere; +} + +.trace-choice small { + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} + +.trace-frame-pre { + min-height: 360px; + max-height: 620px; +} + +.trace-source-note { + margin-top: 8px; + color: var(--muted); +} + +.final-response-block { + margin-top: 12px; + padding: 10px; + border: 1px solid #b9e6cc; + border-radius: 8px; + background: #eefaf4; +} + +.final-response-block.empty, +.final-response-block.unavailable { + border-color: #d8e0ea; + background: #f8fafc; +} + +.final-response-block strong { + display: block; + margin-bottom: 6px; + color: #1f2937; + font-size: 12px; + text-transform: uppercase; +} + +.final-response-text { + min-height: 64px; + max-height: 260px; + margin: 8px 0 0; + padding: 9px; + border: 1px solid #d8e0ea; + border-radius: 8px; + background: #ffffff; + color: #1f2937; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.45; + overflow: auto; + white-space: pre-wrap; +} + .empty-state { padding: 28px 16px; color: var(--muted); @@ -722,6 +820,10 @@ select { grid-column: span 1; } + .trace-reader-grid { + grid-template-columns: 1fr; + } + .metric-card { min-height: 96px; } diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index 7142930f..e9ba12c4 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -69,6 +69,7 @@ const state = { /** @type {SentinelRunDetail|null} */ runDetail: null, runViews: null, + selectedTraceChoiceIndex: 0, lastUpdatedAt: null, filters: readFiltersFromLocation(), findingFilters: readFindingFiltersFromLocation(), @@ -129,7 +130,11 @@ function createDashboardApi() { findings: (filters) => getJson(`/api/findings?${findingsQuery(filters)}`), /** @returns {Promise} */ runDetail: (runId) => getJson(`/api/runs/${encodeURIComponent(runId)}`), - runViews: (runId, view) => getJson(`/api/runs/${encodeURIComponent(runId)}/views?view=${encodeURIComponent(view)}&maxBytes=6000`), + 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()}`); + }, }; } @@ -232,6 +237,7 @@ async function loadDashboard(options) { state.selectedRunId = null; state.runDetail = null; state.runViews = null; + state.selectedTraceChoiceIndex = 0; } renderDashboard(); if (state.selectedRunId && state.runDetail === null) await selectRun(state.selectedRunId); @@ -248,6 +254,7 @@ async function selectRun(runId) { state.selectedRunId = runId; state.runDetail = null; state.runViews = null; + state.selectedTraceChoiceIndex = 0; syncLocationQuery(); renderRuns(); refs.detailSubtitle.textContent = runId; @@ -255,7 +262,7 @@ async function selectRun(runId) { try { const [detail, views] = await Promise.all([ dashboardApi.runDetail(runId), - loadRunViews(runId, "turn-summary"), + loadRunViews(runId), ]); state.runDetail = detail; state.runViews = views; @@ -265,7 +272,7 @@ async function selectRun(runId) { } } -async function loadRunViews(runId, view) { +async function loadRunViews(runId, view = null) { try { return await dashboardApi.runViews(runId, view); } catch (error) { @@ -469,6 +476,7 @@ function renderDetail() { detailArtifacts(artifacts), detailCommands(commands), detailTurnSummary(turnSummaryView), + detailTraceReader(state.runViews, commands), detailEvidence(detail, artifacts), detailBlock("Redaction", [ ["values", detail.valuesRedacted === true ? "redacted" : "-"], @@ -476,6 +484,7 @@ function renderDetail() { ["assistant", detail.redaction?.assistantFinal || "-"], ]), ].join(""); + bindDetailControls(); } function detailBlock(title, rows, className = "") { @@ -526,16 +535,83 @@ function detailCommands(commands) { } function detailTurnSummary(view) { - if (!view) return detailBlock("Turn Summary", [["status", "not indexed"]], "detail-block-wide"); - if (view.ok === false) return detailBlock("Turn Summary", [["status", view.error || "unavailable"]], "detail-block-wide"); + if (!view) return detailBlock("Turn Summary - Layer 1", [["status", "not indexed"]], "detail-block-wide"); + if (view.ok === false) return detailBlock("Turn Summary - Layer 1", [["status", view.error || "unavailable"]], "detail-block-wide"); const text = redactDisplayText(view.renderedText || ""); const note = `${formatNumber(view.renderedTextBytes || text.length)} bytes${view.truncated ? " truncated" : ""}`; - return `
Turn Summary + return `
Turn Summary - Layer 1
${escapeHtml(note)}
${escapeHtml(text || "-")}
`; } +function detailTraceReader(response, commands) { + const turnView = selectedView(response, "turn-summary"); + const traceView = selectedView(response, "trace-frame"); + const choices = traceChoices(turnView?.renderedText || "", traceView?.renderedText || ""); + const selectedIndex = Math.min(state.selectedTraceChoiceIndex, Math.max(0, choices.length - 1)); + const selected = choices[selectedIndex] || { label: "stored trace-frame", meta: "current run", key: "stored" }; + const traceText = traceView?.ok === false ? "" : redactDisplayText(traceView?.renderedText || ""); + const traceNote = traceView + ? `${formatNumber(traceView.renderedTextBytes || traceText.length)} bytes${traceView.truncated ? " truncated" : ""}` + : "trace-frame view not indexed"; + return `
Trace Frame - Layer 2 +
+
+
Turn / trace / sample choices
+
${choices.map((choice, index) => ``).join("")}
+
+
+
Selected: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}
+
${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame unavailable" : "trace-frame view not indexed"))}
+ ${finalResponseBlock(traceView)} +
source=${escapeHtml(commands.traceFrame || "-")} · analyzer findings do not rewrite this text
+
+
+
`; +} + +function finalResponseBlock(traceView) { + if (!traceView) { + return `
Final Response
trace-frame view not indexed
`; + } + if (traceView.ok === false) { + return `
Final Response
${escapeHtml(traceView.error || "trace-frame unavailable")}
`; + } + const block = traceView.finalResponse || {}; + const text = block.empty === true ? "(空内容)" : redactDisplayText(block.text || ""); + const bytes = Number(block.byteCount || 0); + return `
+ Final Response +
${block.empty === true ? "empty" : "available"} · ${formatNumber(bytes)} bytes · values redacted
+
${escapeHtml(text || "(空内容)")}
+
`; +} + +function traceChoices(turnSummaryText, traceFrameText) { + const sourceLines = String(turnSummaryText || "") + .split("\n") + .map((line) => line.trim()) + .filter((line) => /(turn|trace|sample|final response|轮次|用户消息)/iu.test(line)) + .slice(0, 12); + const lines = sourceLines.length > 0 ? sourceLines : String(traceFrameText || "") + .split("\n") + .map((line) => line.trim()) + .filter((line) => /(trace|sample|total=|final response)/iu.test(line)) + .slice(0, 12); + if (lines.length === 0) return [{ label: "stored trace-frame", meta: "current run", 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(" · ") || `line ${index + 1}`; + return { label: shortText(redactDisplayText(line), 120), meta, key: `${trace || "line"}-${sample || index}` }; + }); +} + function detailEvidence(detail, artifacts) { const traceability = detail.traceability || {}; return detailBlock("Evidence", [ @@ -554,6 +630,15 @@ function selectedView(response, viewName) { 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;