feat: add sentinel dashboard trace frame reader (#952)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user