feat: add sentinel dashboard trace frame reader (#952)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-06-26 11:22:45 +08:00
committed by GitHub
parent 22294fd43b
commit a8e28d80b1
2 changed files with 193 additions and 6 deletions
@@ -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;
}
@@ -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<SentinelRunDetail>} */
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 `<article class="detail-block detail-block-wide"><strong>Turn Summary</strong>
return `<article class="detail-block detail-block-wide"><strong>Turn Summary - Layer 1</strong>
<div class="view-note">${escapeHtml(note)}</div>
<pre class="detail-pre">${escapeHtml(text || "-")}</pre>
</article>`;
}
function detailTraceReader(response, commands) {
const turnView = selectedView(response, "turn-summary");
const traceView = selectedView(response, "trace-frame");
const choices = traceChoices(turnView?.renderedText || "", traceView?.renderedText || "");
const selectedIndex = Math.min(state.selectedTraceChoiceIndex, Math.max(0, choices.length - 1));
const selected = choices[selectedIndex] || { label: "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 `<article class="detail-block detail-block-wide trace-reader"><strong>Trace Frame - Layer 2</strong>
<div class="trace-reader-grid">
<section class="trace-turn-picker" aria-label="Turn summary trace choices">
<div class="view-note">Turn / trace / sample choices</div>
<div class="trace-choice-list">${choices.map((choice, index) => `<button type="button" class="trace-choice ${index === selectedIndex ? "active" : ""}" data-trace-choice-index="${escapeAttr(String(index))}">
<span>${escapeHtml(choice.label)}</span>
<small>${escapeHtml(choice.meta)}</small>
</button>`).join("")}</div>
</section>
<section class="trace-frame-view" aria-label="Trace frame text view">
<div class="view-note">Selected: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}</div>
<pre class="detail-pre trace-frame-pre">${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame unavailable" : "trace-frame view not indexed"))}</pre>
${finalResponseBlock(traceView)}
<div class="trace-source-note mono">source=${escapeHtml(commands.traceFrame || "-")} · analyzer findings do not rewrite this text</div>
</section>
</div>
</article>`;
}
function finalResponseBlock(traceView) {
if (!traceView) {
return `<section class="final-response-block unavailable"><strong>Final Response</strong><div>trace-frame view not indexed</div></section>`;
}
if (traceView.ok === false) {
return `<section class="final-response-block unavailable"><strong>Final Response</strong><div>${escapeHtml(traceView.error || "trace-frame unavailable")}</div></section>`;
}
const block = traceView.finalResponse || {};
const text = block.empty === true ? "(空内容)" : redactDisplayText(block.text || "");
const bytes = Number(block.byteCount || 0);
return `<section class="final-response-block ${block.empty === true ? "empty" : ""}">
<strong>Final Response</strong>
<div class="view-note">${block.empty === true ? "empty" : "available"} · ${formatNumber(bytes)} bytes · values redacted</div>
<pre class="final-response-text">${escapeHtml(text || "(空内容)")}</pre>
</section>`;
}
function traceChoices(turnSummaryText, traceFrameText) {
const sourceLines = String(turnSummaryText || "")
.split("\n")
.map((line) => line.trim())
.filter((line) => /(turn|trace|sample|final response|轮次|用户消息)/iu.test(line))
.slice(0, 12);
const lines = sourceLines.length > 0 ? sourceLines : String(traceFrameText || "")
.split("\n")
.map((line) => line.trim())
.filter((line) => /(trace|sample|total=|final response)/iu.test(line))
.slice(0, 12);
if (lines.length === 0) return [{ label: "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;