From 7c259588275a825845f7539581cd1d23b19ba3cd Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 27 Sep 2026 17:26:05 -0300 Subject: [PATCH] feat(web): make FAIR top-contributor rows clickable to their finding Each dashboard "Top contributors" row is now a button: clicking it opens the run it belongs to and pops that finding's detail modal (evidence/impact/PoC). Matches the finding by title, falling back to CWE; if it was recalibrated or merged, opens the run and says so. Fetches the run detail directly to avoid racing loadDetail's async fill. Co-Authored-By: Claude Opus 4.8 --- web/public/app.js | 34 ++++++++++++++++++++++++++++++++-- web/public/style.css | 6 ++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/web/public/app.js b/web/public/app.js index ab9e50c..fa9c589 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -2458,11 +2458,11 @@ async function renderDashboard() {
Top contributors (most likely annual loss)
${contributors.map(({ f, ml }) => ` -
+
`).join('') || '
No findings in range.
'} + `).join('') || '
No findings in range.
'}
@@ -2506,6 +2506,36 @@ async function renderDashboard() { $('#runFilter').value = tr.dataset.target; renderSidebar(); })); + // A top contributor is a specific finding in a specific run — clicking it + // opens that run and pops the finding's full detail (evidence/impact/PoC), + // so "what is this loss" is one click, not a hunt through run history. + $$('#dashBody .contrib-click').forEach((row) => row.addEventListener('click', () => + openContributorFinding(row.dataset.run, row.dataset.title, row.dataset.cwe))); +} + +/// Open the run a dashboard contributor belongs to and surface that exact +/// finding. The dashboard row carries only (runId, title, cwe) — the full +/// finding lives in the run detail — so we load the run, then match by title +/// (falling back to CWE) and open its modal. +async function openContributorFinding(runId, title, cwe) { + if (!runId) return; + const run = (state.runs || []).find((r) => r.id === runId) || { id: runId, state: 'complete' }; + // Switch to the run's detail view (this also refreshes the tables), and in + // parallel fetch the detail directly so we match the finding without racing + // loadDetail's own async fill of state.detailFindings. + openRun(run); + let detail; + try { detail = await api(`/api/runs/${encodeURIComponent(runId)}`); } + catch { return; } + const fs = detail.findings || []; + const norm = (s) => String(s || '').trim().toLowerCase(); + let f = fs.find((x) => norm(x.title) === norm(title)); + if (!f && cwe) f = fs.find((x) => norm(x.cwe) === norm(cwe)); + if (f) { + openFindingModal(f, detail.pocs || [], runId); + } else { + toast('Opened the run — this finding was recalibrated or merged, so its row may differ.', 'warn', 6000); + } } /// The assumptions panel. FAIR without visible inputs is a magic number; with diff --git a/web/public/style.css b/web/public/style.css index 0c1f994..4f5f684 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -284,6 +284,12 @@ a { color: var(--accent); text-decoration: none; } .fair-note { font-size: var(--fs-xs); color: var(--text-dim); margin-top: var(--sp-3); } .contrib-row { display: flex; align-items: center; gap: var(--sp-3); padding: 5px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); } .contrib-row:last-child { border-bottom: none; } +/* Clickable contributor: a full-width button that reads like a row but opens + the finding. */ +button.contrib-row { width: 100%; text-align: left; background: transparent; border-left: none; border-right: none; border-top: none; color: var(--text); cursor: pointer; padding: 5px var(--sp-2); margin: 0 calc(-1 * var(--sp-2)); border-radius: var(--radius-xs); transition: background var(--t-fast); } +button.contrib-row:hover { background: var(--surface-3); } +button.contrib-row:hover .contrib-title { color: var(--accent); } +button.contrib-row .contrib-v { margin-left: auto; } .contrib-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .contrib-v { font-family: var(--mono); font-size: var(--fs-sm); } .fair-params { display: flex; flex-direction: column; gap: var(--sp-2); }