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 <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-09-27 17:26:05 -03:00
co-authored by Claude Opus 4.8
parent f82e3fe265
commit 7c25958827
2 changed files with 38 additions and 2 deletions
+32 -2
View File
@@ -2458,11 +2458,11 @@ async function renderDashboard() {
<div class="fair-contrib">
<div class="dash-sub">Top contributors (most likely annual loss)</div>
${contributors.map(({ f, ml }) => `
<div class="contrib-row" title="${esc(f.title)} — ${esc(f.target)}">
<button class="contrib-row contrib-click" data-run="${esc(f.runId || '')}" data-title="${esc(f.title || '')}" data-cwe="${esc(f.cwe || '')}" title="Open this finding — ${esc(f.title)} (${esc(f.target)})">
<span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span>
<span class="contrib-title">${esc(f.title || f.cwe || 'untitled')}</span>
<span class="contrib-v">${money(ml)}</span>
</div>`).join('') || '<div class="field-help">No findings in range.</div>'}
</button>`).join('') || '<div class="field-help">No findings in range.</div>'}
</div>
</section>
@@ -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
+6
View File
@@ -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); }