diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css index 8bf26b46..fb9bb910 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -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; + } } diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index 9e9c9915..7142930f 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -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, severityCounts?: Record, freshness?: Record, 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|null}} SentinelRunView + * @typedef {{ok?: boolean, run?: SentinelRun, summary?: Record, findings?: Record[], artifacts?: Record, commands?: Record, redaction?: Record, 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} */ runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`), /** @returns {Promise} */ - findings: () => getJson("/api/findings?limit=20&window=24h"), + 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`), }; } @@ -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 = '
Loading
'; 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 = `
${escapeHtml(error.message || String(error))}
`; } } +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 = '
No findings
'; @@ -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 `
+ const hasLatestRun = latestRunId !== "-"; + return `
${escapeHtml(code)} ${escapeHtml(item.severity || "unknown")}
-
${escapeHtml(item.scenarioId || "-")} · count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))}
-
${escapeHtml(latestRunId)}
-
${escapeHtml(item.summary || "")}
+
+ + +
+
count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))} · latest=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}
+
run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}
+
${escapeHtml(shortText(item.summary || "", 180))}
+
`; }).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 = '
No finding aggregation
'; + 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"), + `
Window
`, + ].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 + ? '-' + : entries.map((entry) => ``).join(""); + return `
${escapeHtml(label)}${chips}
`; +} + +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 `
${escapeHtml(title)}
${ - rows.map(([key, value]) => `
${escapeHtml(key)}: ${escapeHtml(value)}
`).join("") +function detailBlock(title, rows, className = "") { + const visibleRows = rows.length > 0 ? rows : [["-", "-"]]; + return `
${escapeHtml(title)}
${ + visibleRows.map(([key, value]) => `
${escapeHtml(key)}: ${escapeHtml(shortText(value, 180))}
`).join("") }
`; } +function detailFindings(findings) { + if (findings.length === 0) return detailBlock("Run Findings", [["status", "none"]], "detail-block-wide"); + return `
Run Findings +
+ + + ${findings.map((item) => ` + + + + + + `).join("")} +
SeverityCodeCountSummaryReport
${escapeHtml(item.severity || "-")}${escapeHtml(item.finding_id || item.findingId || "-")}${escapeHtml(String(item.count ?? 0))}${escapeHtml(shortText(item.summary || "", 220))}${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}
+
+
`; +} + +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 `
CLI Commands +
${rows.map(([label, command]) => `
${escapeHtml(label)}${escapeHtml(command)}
`).join("")}
+
`; +} + +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 `
Turn Summary +
${escapeHtml(note)}
+
${escapeHtml(text || "-")}
+
`; +} + +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) { diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts index 22eb4199..4942bbf0 100644 --- a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts +++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts @@ -173,11 +173,42 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig

Findings

-
+
+ + + + + +
+
-
+