feat: enhance sentinel dashboard findings detail (#949)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -365,22 +365,32 @@ select {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.runs-filter {
|
||||
.runs-filter,
|
||||
.findings-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr)) minmax(180px, 1.4fr) auto;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.runs-filter label {
|
||||
.runs-filter {
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr)) minmax(180px, 1.4fr) auto;
|
||||
}
|
||||
|
||||
.findings-filter {
|
||||
grid-template-columns: repeat(2, minmax(96px, 0.9fr)) repeat(2, minmax(120px, 1.1fr)) auto;
|
||||
}
|
||||
|
||||
.runs-filter label,
|
||||
.findings-filter label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runs-filter label span {
|
||||
.runs-filter label span,
|
||||
.findings-filter label span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
@@ -388,11 +398,14 @@ select {
|
||||
}
|
||||
|
||||
.runs-filter input,
|
||||
.runs-filter select {
|
||||
.runs-filter select,
|
||||
.findings-filter input,
|
||||
.findings-filter select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.runs-filter input {
|
||||
.runs-filter input,
|
||||
.findings-filter input {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -401,7 +414,8 @@ select {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.runs-filter .icon-button {
|
||||
.runs-filter .icon-button,
|
||||
.findings-filter .icon-button {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
@@ -460,6 +474,29 @@ select {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.finding-aggregation {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.aggregation-group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aggregation-group > span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.finding-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -482,6 +519,49 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.finding-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-chip,
|
||||
.link-button {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #344054;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
border-color: #bdd7ff;
|
||||
background: #edf4ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
justify-self: start;
|
||||
min-height: 30px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.link-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.finding-meta,
|
||||
.detail-grid {
|
||||
color: var(--muted);
|
||||
@@ -506,6 +586,10 @@ select {
|
||||
border-left: 3px solid #dbe5ee;
|
||||
}
|
||||
|
||||
.detail-block-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.detail-block strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
@@ -514,6 +598,80 @@ select {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-table-frame {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table th,
|
||||
.detail-table td {
|
||||
padding: 8px 9px;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-list div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.command-list span,
|
||||
.view-note {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-list code,
|
||||
.detail-pre {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #1f2937;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.command-list code {
|
||||
padding: 8px 9px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-pre {
|
||||
min-height: 160px;
|
||||
max-height: 420px;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px 16px;
|
||||
color: var(--muted);
|
||||
@@ -521,6 +679,11 @@ select {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -550,6 +713,15 @@ select {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.findings-filter,
|
||||
.finding-aggregation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-block-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 96px;
|
||||
}
|
||||
@@ -584,4 +756,8 @@ select {
|
||||
.runs-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.findings-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
// Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering.
|
||||
|
||||
/**
|
||||
* @typedef {{runId?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null}} Traceability
|
||||
* @typedef {{runId?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, stateRoot?: string|null, source?: string|null}} Traceability
|
||||
* @typedef {{id?: string|null, runId?: string|null, scenarioId?: string|null, status?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, findingCount?: number, artifactCount?: number, updatedAt?: string|null, maxSeverity?: string|null, traceability?: Traceability}} SentinelRun
|
||||
* @typedef {{ok: boolean, status?: string, node?: string, lane?: string, publicOrigin?: string|null, latestRun?: SentinelRun|null, runCounts?: Record<string, number>, severityCounts?: Record<string, number>, freshness?: Record<string, unknown>, targetValidation?: {maxSeconds?: number, scenarioId?: string|null}, valuesRedacted?: boolean}} SentinelOverview
|
||||
* @typedef {{groups?: Array<{code?: string|null, findingId?: string|null, severity?: string|null, scenarioId?: string|null, count?: number, runCount?: number, latestAt?: string|null, latestRunId?: string|null, summary?: string|null}>}} SentinelFindingsResponse
|
||||
* @typedef {{code?: string|null, findingId?: string|null, severity?: string|null, scenarioId?: string|null, count?: number, runCount?: number, latestAt?: string|null, latestRunId?: string|null, latestReportJsonSha256?: string|null, summary?: string|null, traceability?: Traceability|null}} SentinelFindingGroup
|
||||
* @typedef {{groups?: SentinelFindingGroup[], findings?: SentinelFindingGroup[], page?: {limit?: number, hasMore?: boolean}}} SentinelFindingsResponse
|
||||
* @typedef {{items?: SentinelRun[], runs?: SentinelRun[], page?: {limit?: number, nextCursor?: string|null, hasMore?: boolean}}} SentinelRunsResponse
|
||||
* @typedef {{ok?: boolean, view?: string, renderedText?: string, renderedTextBytes?: number, truncated?: boolean, error?: string|null, finalResponse?: Record<string, unknown>|null}} SentinelRunView
|
||||
* @typedef {{ok?: boolean, run?: SentinelRun, summary?: Record<string, unknown>, findings?: Record<string, unknown>[], artifacts?: Record<string, unknown>, commands?: Record<string, string>, redaction?: Record<string, unknown>, traceability?: Traceability, viewsAvailable?: string[], valuesRedacted?: boolean}} SentinelRunDetail
|
||||
*/
|
||||
|
||||
const root = document.getElementById("sentinel-dashboard");
|
||||
@@ -41,6 +44,13 @@ const refs = {
|
||||
clearFilters: document.getElementById("clear-filters"),
|
||||
runsBody: document.getElementById("runs-body"),
|
||||
runsCount: document.getElementById("runs-count"),
|
||||
findingsFilterForm: document.getElementById("findings-filter"),
|
||||
findingFilterSeverity: document.getElementById("finding-filter-severity"),
|
||||
findingFilterWindow: document.getElementById("finding-filter-window"),
|
||||
findingFilterCode: document.getElementById("finding-filter-code"),
|
||||
findingFilterScenario: document.getElementById("finding-filter-scenario"),
|
||||
findingClearFilters: document.getElementById("finding-clear-filters"),
|
||||
findingAggregation: document.getElementById("finding-aggregation"),
|
||||
findingsList: document.getElementById("findings-list"),
|
||||
findingsCount: document.getElementById("findings-count"),
|
||||
detailSubtitle: document.getElementById("detail-subtitle"),
|
||||
@@ -54,10 +64,14 @@ const state = {
|
||||
overview: null,
|
||||
/** @type {SentinelRun[]} */
|
||||
runs: [],
|
||||
/** @type {SentinelFindingGroup[]} */
|
||||
findings: [],
|
||||
/** @type {SentinelRunDetail|null} */
|
||||
runDetail: null,
|
||||
runViews: null,
|
||||
lastUpdatedAt: null,
|
||||
filters: readFiltersFromLocation(),
|
||||
findingFilters: readFindingFiltersFromLocation(),
|
||||
};
|
||||
|
||||
const dashboardApi = createDashboardApi();
|
||||
@@ -83,12 +97,26 @@ refs.clearFilters.addEventListener("click", () => {
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
});
|
||||
refs.findingsFilterForm.addEventListener("submit", (event) => event.preventDefault());
|
||||
for (const control of [refs.findingFilterSeverity, refs.findingFilterWindow]) {
|
||||
control.addEventListener("change", () => applyFindingFilterControls());
|
||||
}
|
||||
for (const control of [refs.findingFilterCode, refs.findingFilterScenario]) {
|
||||
control.addEventListener("input", debounce(() => applyFindingFilterControls(), 250));
|
||||
}
|
||||
refs.findingClearFilters.addEventListener("click", () => {
|
||||
state.findingFilters = { severity: "", window: "24h", code: "", scenario: "" };
|
||||
writeFindingFiltersToControls();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && autoRefresh.enabled()) loadDashboard({ silent: true });
|
||||
});
|
||||
|
||||
hydrateControls();
|
||||
writeFiltersToControls();
|
||||
writeFindingFiltersToControls();
|
||||
loadDashboard({ silent: false }).catch((error) => renderError(error));
|
||||
|
||||
function createDashboardApi() {
|
||||
@@ -98,8 +126,10 @@ function createDashboardApi() {
|
||||
/** @returns {Promise<SentinelRunsResponse>} */
|
||||
runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`),
|
||||
/** @returns {Promise<SentinelFindingsResponse>} */
|
||||
findings: () => getJson("/api/findings?limit=20&window=24h"),
|
||||
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`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,17 +220,18 @@ async function loadDashboard(options) {
|
||||
const [overview, runs, findings] = await Promise.all([
|
||||
dashboardApi.overview(),
|
||||
dashboardApi.runs(state.filters),
|
||||
dashboardApi.findings(),
|
||||
dashboardApi.findings(state.findingFilters),
|
||||
]);
|
||||
state.overview = overview;
|
||||
state.runs = Array.isArray(runs.items) ? runs.items : Array.isArray(runs.runs) ? runs.runs : [];
|
||||
state.findings = Array.isArray(findings.groups) ? findings.groups : [];
|
||||
state.findings = Array.isArray(findings.groups) ? findings.groups : Array.isArray(findings.findings) ? findings.findings : [];
|
||||
state.lastUpdatedAt = new Date();
|
||||
const queryRun = new URLSearchParams(window.location.search).get("run");
|
||||
if (queryRun && state.selectedRunId === null) state.selectedRunId = queryRun;
|
||||
if (state.selectedRunId && !state.runs.some((run) => run.runId === state.selectedRunId || run.id === state.selectedRunId)) {
|
||||
state.selectedRunId = null;
|
||||
state.runDetail = null;
|
||||
state.runViews = null;
|
||||
}
|
||||
renderDashboard();
|
||||
if (state.selectedRunId && state.runDetail === null) await selectRun(state.selectedRunId);
|
||||
@@ -215,18 +246,33 @@ async function loadDashboard(options) {
|
||||
async function selectRun(runId) {
|
||||
if (!runId) return;
|
||||
state.selectedRunId = runId;
|
||||
state.runDetail = null;
|
||||
state.runViews = null;
|
||||
syncLocationQuery();
|
||||
renderRuns();
|
||||
refs.detailSubtitle.textContent = runId;
|
||||
refs.detailContent.innerHTML = '<div class="empty-state">Loading</div>';
|
||||
try {
|
||||
state.runDetail = await dashboardApi.runDetail(runId);
|
||||
const [detail, views] = await Promise.all([
|
||||
dashboardApi.runDetail(runId),
|
||||
loadRunViews(runId, "turn-summary"),
|
||||
]);
|
||||
state.runDetail = detail;
|
||||
state.runViews = views;
|
||||
renderDetail();
|
||||
} catch (error) {
|
||||
refs.detailContent.innerHTML = `<div class="empty-state">${escapeHtml(error.message || String(error))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRunViews(runId, view) {
|
||||
try {
|
||||
return await dashboardApi.runViews(runId, view);
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.message || String(error), valuesRedacted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
refs.errorBanner.hidden = true;
|
||||
renderOverview();
|
||||
@@ -310,6 +356,7 @@ function renderRuns() {
|
||||
}
|
||||
|
||||
function renderFindings() {
|
||||
renderFindingAggregation();
|
||||
refs.findingsCount.textContent = `${state.findings.length} groups`;
|
||||
if (state.findings.length === 0) {
|
||||
refs.findingsList.innerHTML = '<div class="empty-state">No findings</div>';
|
||||
@@ -318,16 +365,75 @@ function renderFindings() {
|
||||
refs.findingsList.innerHTML = state.findings.map((item) => {
|
||||
const code = item.code || item.findingId || "finding";
|
||||
const latestRunId = item.latestRunId || "-";
|
||||
return `<article class="finding-item">
|
||||
const hasLatestRun = latestRunId !== "-";
|
||||
return `<article class="finding-item" data-finding-run-id="${escapeAttr(latestRunId)}">
|
||||
<div class="finding-row">
|
||||
<span class="finding-title">${escapeHtml(code)}</span>
|
||||
<span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(item.severity || "unknown")}</span>
|
||||
</div>
|
||||
<div class="finding-meta">${escapeHtml(item.scenarioId || "-")} · count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))}</div>
|
||||
<div class="finding-meta mono">${escapeHtml(latestRunId)}</div>
|
||||
<div class="finding-meta">${escapeHtml(item.summary || "")}</div>
|
||||
<div class="finding-actions" aria-label="Finding filters">
|
||||
<button type="button" class="filter-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(code)}</button>
|
||||
<button type="button" class="filter-chip" data-finding-filter="scenario" data-filter-value="${escapeAttr(item.scenarioId || "")}">${escapeHtml(item.scenarioId || "-")}</button>
|
||||
</div>
|
||||
<div class="finding-meta">count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))} · latest=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}</div>
|
||||
<div class="finding-meta mono">run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}</div>
|
||||
<div class="finding-meta">${escapeHtml(shortText(item.summary || "", 180))}</div>
|
||||
<button type="button" class="link-button" data-open-finding-run="${escapeAttr(latestRunId)}"${hasLatestRun ? "" : " disabled"}>Open latest run</button>
|
||||
</article>`;
|
||||
}).join("");
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) {
|
||||
button.addEventListener("click", () => selectRun(button.dataset.openFindingRun));
|
||||
}
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-finding-filter]")) {
|
||||
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFindingAggregation() {
|
||||
if (state.findings.length === 0) {
|
||||
refs.findingAggregation.innerHTML = '<div class="empty-state compact">No finding aggregation</div>';
|
||||
return;
|
||||
}
|
||||
const severityEntries = aggregateFindings((item) => item.severity || "unknown").slice(0, 5);
|
||||
const codeEntries = aggregateFindings((item) => item.code || item.findingId || "unknown").slice(0, 5);
|
||||
const scenarioEntries = aggregateFindings((item) => item.scenarioId || "unknown").slice(0, 5);
|
||||
refs.findingAggregation.innerHTML = [
|
||||
aggregationGroup("Severity", severityEntries, "severity"),
|
||||
aggregationGroup("Code", codeEntries, "code"),
|
||||
aggregationGroup("Scenario", scenarioEntries, "scenario"),
|
||||
`<div class="aggregation-group"><span>Window</span><button type="button" class="filter-chip active">${escapeHtml(state.findingFilters.window || "all")}</button></div>`,
|
||||
].join("");
|
||||
for (const button of refs.findingAggregation.querySelectorAll("[data-finding-filter]")) {
|
||||
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
}
|
||||
}
|
||||
|
||||
function aggregationGroup(label, entries, filterKey) {
|
||||
const chips = entries.length === 0
|
||||
? '<span class="finding-meta">-</span>'
|
||||
: entries.map((entry) => `<button type="button" class="filter-chip" data-finding-filter="${escapeAttr(filterKey)}" data-filter-value="${escapeAttr(entry.key)}">${escapeHtml(entry.key)} ${formatNumber(entry.count)}</button>`).join("");
|
||||
return `<div class="aggregation-group"><span>${escapeHtml(label)}</span>${chips}</div>`;
|
||||
}
|
||||
|
||||
function aggregateFindings(keyFn) {
|
||||
const counts = new Map();
|
||||
for (const item of state.findings) {
|
||||
const key = keyFn(item);
|
||||
counts.set(key, (counts.get(key) || 0) + Number(item.count || 0));
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function applyFindingFilter(key, value) {
|
||||
if (!value || value === "-") return;
|
||||
if (key === "severity") state.findingFilters.severity = value;
|
||||
if (key === "code") state.findingFilters.code = value;
|
||||
if (key === "scenario") state.findingFilters.scenario = value;
|
||||
writeFindingFiltersToControls();
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
@@ -338,6 +444,10 @@ function renderDetail() {
|
||||
}
|
||||
const detail = state.runDetail;
|
||||
const run = detail.run || {};
|
||||
const findings = Array.isArray(detail.findings) ? detail.findings : [];
|
||||
const artifacts = detail.artifacts || {};
|
||||
const commands = detail.commands || {};
|
||||
const turnSummaryView = selectedView(state.runViews, "turn-summary");
|
||||
refs.detailSubtitle.textContent = run.runId || state.selectedRunId;
|
||||
refs.detailContent.innerHTML = [
|
||||
detailBlock("Traceability", [
|
||||
@@ -351,11 +461,15 @@ function renderDetail() {
|
||||
["scenario", run.scenarioId || "-"],
|
||||
["findings", String(run.findingCount ?? 0)],
|
||||
["artifacts", String(run.artifactCount ?? 0)],
|
||||
["updated", run.updatedAt || "-"],
|
||||
["views", Array.isArray(detail.viewsAvailable) ? detail.viewsAvailable.join(", ") : "-"],
|
||||
]),
|
||||
detailBlock("Commands", [
|
||||
["summary", detail.commands?.summary || "-"],
|
||||
["trace", detail.commands?.traceFrame || "-"],
|
||||
]),
|
||||
detailBlock("Report Summary", safeSummaryRows(detail.summary), "detail-block-wide"),
|
||||
detailFindings(findings),
|
||||
detailArtifacts(artifacts),
|
||||
detailCommands(commands),
|
||||
detailTurnSummary(turnSummaryView),
|
||||
detailEvidence(detail, artifacts),
|
||||
detailBlock("Redaction", [
|
||||
["values", detail.valuesRedacted === true ? "redacted" : "-"],
|
||||
["prompt", detail.redaction?.prompt || "-"],
|
||||
@@ -364,12 +478,82 @@ function renderDetail() {
|
||||
].join("");
|
||||
}
|
||||
|
||||
function detailBlock(title, rows) {
|
||||
return `<article class="detail-block"><strong>${escapeHtml(title)}</strong><div class="detail-grid">${
|
||||
rows.map(([key, value]) => `<div><span>${escapeHtml(key)}:</span> <span class="mono">${escapeHtml(value)}</span></div>`).join("")
|
||||
function detailBlock(title, rows, className = "") {
|
||||
const visibleRows = rows.length > 0 ? rows : [["-", "-"]];
|
||||
return `<article class="detail-block ${escapeAttr(className)}"><strong>${escapeHtml(title)}</strong><div class="detail-grid">${
|
||||
visibleRows.map(([key, value]) => `<div><span>${escapeHtml(key)}:</span> <span class="mono">${escapeHtml(shortText(value, 180))}</span></div>`).join("")
|
||||
}</div></article>`;
|
||||
}
|
||||
|
||||
function detailFindings(findings) {
|
||||
if (findings.length === 0) return detailBlock("Run Findings", [["status", "none"]], "detail-block-wide");
|
||||
return `<article class="detail-block detail-block-wide"><strong>Run Findings</strong>
|
||||
<div class="detail-table-frame">
|
||||
<table class="detail-table">
|
||||
<thead><tr><th>Severity</th><th>Code</th><th>Count</th><th>Summary</th><th>Report</th></tr></thead>
|
||||
<tbody>${findings.map((item) => `<tr>
|
||||
<td><span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(item.severity || "-")}</span></td>
|
||||
<td class="mono">${escapeHtml(item.finding_id || item.findingId || "-")}</td>
|
||||
<td>${escapeHtml(String(item.count ?? 0))}</td>
|
||||
<td>${escapeHtml(shortText(item.summary || "", 220))}</td>
|
||||
<td class="mono">${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}</td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function detailArtifacts(artifacts) {
|
||||
const screenshot = artifacts.screenshot || {};
|
||||
return detailBlock("Artifacts", [
|
||||
["artifactCount", String(artifacts.artifactCount ?? "-")],
|
||||
["reportJsonSha256", artifacts.reportJsonSha256 || "-"],
|
||||
["screenshotPath", screenshot.path || "-"],
|
||||
["screenshotSha256", screenshot.sha256 || "-"],
|
||||
["publicOrigin", artifacts.publicOrigin || "-"],
|
||||
], "detail-block-wide");
|
||||
}
|
||||
|
||||
function detailCommands(commands) {
|
||||
const rows = [
|
||||
["summary", commands.summary || "-"],
|
||||
["turn-summary", commands.turnSummary || "-"],
|
||||
["trace-frame", commands.traceFrame || "-"],
|
||||
];
|
||||
return `<article class="detail-block detail-block-wide"><strong>CLI Commands</strong>
|
||||
<div class="command-list">${rows.map(([label, command]) => `<div><span>${escapeHtml(label)}</span><code>${escapeHtml(command)}</code></div>`).join("")}</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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");
|
||||
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>
|
||||
<div class="view-note">${escapeHtml(note)}</div>
|
||||
<pre class="detail-pre">${escapeHtml(text || "-")}</pre>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function detailEvidence(detail, artifacts) {
|
||||
const traceability = detail.traceability || {};
|
||||
return detailBlock("Evidence", [
|
||||
["source", traceability.source || "-"],
|
||||
["stateRoot", traceability.stateRoot || "-"],
|
||||
["stateDir", traceability.stateDir || "-"],
|
||||
["observerId", traceability.observerId || "-"],
|
||||
["runId", traceability.runId || "-"],
|
||||
["reportJsonSha256", traceability.reportJsonSha256 || artifacts.reportJsonSha256 || "-"],
|
||||
], "detail-block-wide");
|
||||
}
|
||||
|
||||
function selectedView(response, viewName) {
|
||||
if (response?.ok === false) return { ok: false, error: response.error || "unavailable", view: viewName };
|
||||
const views = Array.isArray(response?.views) ? response.views : [];
|
||||
return views.find((item) => item.view === viewName) || null;
|
||||
}
|
||||
|
||||
function renderLoading(show) {
|
||||
refs.loadingBanner.hidden = !show;
|
||||
refs.manualRefresh.disabled = state.loading;
|
||||
@@ -393,6 +577,17 @@ function applyFilterControls() {
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
|
||||
function applyFindingFilterControls() {
|
||||
state.findingFilters = {
|
||||
severity: refs.findingFilterSeverity.value,
|
||||
window: refs.findingFilterWindow.value,
|
||||
code: refs.findingFilterCode.value.trim(),
|
||||
scenario: refs.findingFilterScenario.value.trim(),
|
||||
};
|
||||
syncLocationQuery();
|
||||
loadDashboard({ silent: false });
|
||||
}
|
||||
|
||||
function writeFiltersToControls() {
|
||||
refs.filterStatus.value = state.filters.status || "";
|
||||
refs.filterSeverity.value = state.filters.severity || "";
|
||||
@@ -401,6 +596,13 @@ function writeFiltersToControls() {
|
||||
refs.filterSort.value = state.filters.sort || "updated";
|
||||
}
|
||||
|
||||
function writeFindingFiltersToControls() {
|
||||
refs.findingFilterSeverity.value = state.findingFilters.severity || "";
|
||||
refs.findingFilterWindow.value = state.findingFilters.window || "";
|
||||
refs.findingFilterCode.value = state.findingFilters.code || "";
|
||||
refs.findingFilterScenario.value = state.findingFilters.scenario || "";
|
||||
}
|
||||
|
||||
function readFiltersFromLocation() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
@@ -412,11 +614,30 @@ function readFiltersFromLocation() {
|
||||
};
|
||||
}
|
||||
|
||||
function readFindingFiltersFromLocation() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
severity: query.get("fseverity") || "",
|
||||
window: query.get("fwindow") || "24h",
|
||||
code: query.get("fcode") || "",
|
||||
scenario: query.get("fscenario") || "",
|
||||
};
|
||||
}
|
||||
|
||||
function syncLocationQuery() {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(state.filters)) {
|
||||
if (value && !(key === "sort" && value === "updated")) query.set(key, value);
|
||||
}
|
||||
const findingQueryKeys = {
|
||||
severity: "fseverity",
|
||||
window: "fwindow",
|
||||
code: "fcode",
|
||||
scenario: "fscenario",
|
||||
};
|
||||
for (const [key, value] of Object.entries(state.findingFilters)) {
|
||||
if (value && !(key === "window" && value === "24h")) query.set(findingQueryKeys[key], value);
|
||||
}
|
||||
if (state.selectedRunId) query.set("run", state.selectedRunId);
|
||||
const next = `${window.location.pathname}${query.toString() ? `?${query.toString()}` : ""}`;
|
||||
window.history.replaceState(null, "", next);
|
||||
@@ -432,6 +653,15 @@ function runsQuery(filters) {
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function findingsQuery(filters) {
|
||||
const query = new URLSearchParams({ limit: "30" });
|
||||
if (filters.severity) query.set("severity", filters.severity);
|
||||
if (filters.window) query.set("window", filters.window);
|
||||
if (filters.code) query.set("code", filters.code);
|
||||
if (filters.scenario) query.set("scenario", filters.scenario);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function windowFrom(value) {
|
||||
if (!value) return "";
|
||||
const match = /^(\d+)(h|d)$/u.exec(value);
|
||||
@@ -449,6 +679,48 @@ function debounce(fn, waitMs) {
|
||||
};
|
||||
}
|
||||
|
||||
function safeSummaryRows(summary) {
|
||||
const source = summary && typeof summary === "object" ? summary : {};
|
||||
const rows = [];
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!safeDisplayKey(key)) continue;
|
||||
if (rows.length >= 8) break;
|
||||
rows.push([key, safeDisplayValue(value)]);
|
||||
}
|
||||
return rows.length === 0 ? [["status", "not indexed"]] : rows;
|
||||
}
|
||||
|
||||
function safeDisplayKey(key) {
|
||||
return !/(prompt|provider|cookie|token|authorization|payload|stdout|stderr|assistant|secret|password)/iu.test(key);
|
||||
}
|
||||
|
||||
function safeDisplayValue(value) {
|
||||
if (value === null || typeof value === "undefined") return "-";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} items`;
|
||||
const entries = Object.entries(value).filter(([key]) => safeDisplayKey(key)).slice(0, 4);
|
||||
if (entries.length === 0) return "redacted object";
|
||||
return entries.map(([key, item]) => `${key}=${primitiveValue(item)}`).join(", ");
|
||||
}
|
||||
|
||||
function primitiveValue(value) {
|
||||
if (value === null || typeof value === "undefined") return "-";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return shortText(String(value), 80);
|
||||
if (Array.isArray(value)) return `${value.length} items`;
|
||||
return "object";
|
||||
}
|
||||
|
||||
function redactDisplayText(value) {
|
||||
return String(value || "")
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/(prompt|cookie|token|authorization|provider|payload|api[_-]?key|password|secret|stdout|stderr)/iu.test(line)) return "[redacted]";
|
||||
if (/assistant/iu.test(line) && line.length > 180) return "[assistant text redacted]";
|
||||
return shortText(line, 360);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function renderError(error) {
|
||||
refs.errorBanner.hidden = false;
|
||||
refs.errorBanner.textContent = error?.message || String(error);
|
||||
@@ -482,7 +754,7 @@ function formatNumber(value) {
|
||||
|
||||
function shortText(value, limit) {
|
||||
const text = String(value || "");
|
||||
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
||||
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
|
||||
}
|
||||
|
||||
function formatRelative(iso) {
|
||||
|
||||
@@ -173,11 +173,42 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
|
||||
<h2 id="findings-heading">Findings</h2>
|
||||
<span id="findings-count" class="panel-subtitle">-</span>
|
||||
</div>
|
||||
<form id="findings-filter" class="findings-filter">
|
||||
<label>
|
||||
<span>Severity</span>
|
||||
<select id="finding-filter-severity" name="fseverity">
|
||||
<option value="">Any</option>
|
||||
<option value="red">red</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Window</span>
|
||||
<select id="finding-filter-window" name="fwindow">
|
||||
<option value="24h">24h</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="6h">6h</option>
|
||||
<option value="7d">7d</option>
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Code</span>
|
||||
<input id="finding-filter-code" name="fcode" type="search" placeholder="finding code">
|
||||
</label>
|
||||
<label>
|
||||
<span>Scenario</span>
|
||||
<input id="finding-filter-scenario" name="fscenario" type="search" placeholder="scenario">
|
||||
</label>
|
||||
<button id="finding-clear-filters" class="icon-button" type="button">Clear</button>
|
||||
</form>
|
||||
<div id="finding-aggregation" class="finding-aggregation"></div>
|
||||
<div id="findings-list" class="finding-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel detail-panel" aria-labelledby="detail-heading">
|
||||
<section class="panel detail-panel" aria-labelledby="detail-heading" role="dialog" aria-modal="false">
|
||||
<div class="panel-header">
|
||||
<h2 id="detail-heading">Run Detail</h2>
|
||||
<span id="detail-subtitle" class="panel-subtitle">No run selected</span>
|
||||
|
||||
Reference in New Issue
Block a user