feat(ui): hide/exclude models from the dashboard (persisted) #1

Merged
steve merged 1 commits from feat/ui-hide-models into main 2026-06-28 00:39:33 +00:00
2 changed files with 48 additions and 3 deletions
+6
View File
@@ -159,6 +159,12 @@ Its mirror, **solo-error penalty ×** (default `1.5`), multiplies the FP penalty
was made by **only that model** — a unique wrong claim is noisier than a shared mistake. So a was made by **only that model** — a unique wrong claim is noisier than a shared mistake. So a
Blocking-claimed solo FP costs `high(8) × -0.5 × 1.5 = -6` vs `-4` for a shared one. Set to `1` to disable. Blocking-claimed solo FP costs `high(8) × -0.5 × 1.5 = -6` vs `-4` for a shared one. Set to `1` to disable.
**Hiding models.** Each scoreboard row has a small **×** to hide that model — handy for retired ones
(e.g. `m1`) you no longer want cluttering the view. Hidden models drop out of the table, the totals,
and the findings drill-down (but **not** from solo-ness, which stays computed against all models — hiding
is a view filter, not a rescoring). The hidden set persists in `localStorage` across reloads; a
**hidden (N): …** bar lists them as click-to-restore chips, with a **show all** to clear.
Auth: the `/ui` shell is public (it holds no data); paste the store token into its **connect** box, Auth: the `/ui` shell is public (it holds no data); paste the store token into its **connect** box,
or open `/ui?token=<token>` once (remembered in `localStorage`). Prefer your own dashboard? Point or open `/ui?token=<token>` once (remembered in `localStorage`). Prefer your own dashboard? Point
Grafana/Metabase/etc. at the SQLite file or the same `/export` + `/scoreboard` + `/runs` JSON. Grafana/Metabase/etc. at the SQLite file or the same `/export` + `/scoreboard` + `/runs` JSON.
+41 -2
View File
@@ -88,6 +88,7 @@
<div class="panel"> <div class="panel">
<div id="summary" class="small mut" style="margin-bottom:8px"></div> <div id="summary" class="small mut" style="margin-bottom:8px"></div>
<div id="hidden" class="small mut" style="margin-bottom:8px;display:none"></div>
<table id="models"> <table id="models">
<thead><tr id="mhead"></tr></thead> <thead><tr id="mhead"></tr></thead>
<tbody id="mbody"></tbody> <tbody id="mbody"></tbody>
@@ -115,6 +116,17 @@ const SEVCOLOR = { trivial:"#3b4252", small:"#2e4d3a", medium:"#4d4a2e", high:"#
let RUNS = [], ROWS = []; let RUNS = [], ROWS = [];
let sortKey = "ptsPerMin", sortAsc = false, selModel = null; let sortKey = "ptsPerMin", sortAsc = false, selModel = null;
// Persistently-excluded models (e.g. retired ones like m1). Hidden from the
// scoreboard, totals, and drill-down; persisted in localStorage across reloads.
// Solo-ness is still computed against ALL models (hiding is a view filter, not a
// rescoring), so hiding one model never fakes another's solo finds.
function loadHidden(){ try { return new Set(JSON.parse(localStorage.getItem("grt-hidden") || "[]")); } catch { return new Set(); } }
let HIDDEN = loadHidden();
function saveHidden(){ localStorage.setItem("grt-hidden", JSON.stringify([...HIDDEN].sort())); }
function hideModel(m){ HIDDEN.add(m); if (selModel===m) selModel=null; saveHidden(); render(); }
function showModel(m){ HIDDEN.delete(m); saveHidden(); render(); }
function showAllModels(){ HIDDEN.clear(); saveHidden(); render(); }
function token(){ function token(){
const q = new URL(location.href).searchParams.get("token"); const q = new URL(location.href).searchParams.get("token");
if (q) { localStorage.setItem("grt", q); return q; } if (q) { localStorage.setItem("grt", q); return q; }
@@ -229,7 +241,7 @@ function aggregate(f){
for (const r of RUNS){ if(!runMatch(r,f)) continue; const m=get(r.model); m.runs++; m.minutes += (r.duration_secs||0)/60; for (const r of RUNS){ if(!runMatch(r,f)) continue; const m=get(r.model); m.runs++; m.minutes += (r.duration_secs||0)/60;
m.inTok += r.input_tokens||0; m.outTok += r.output_tokens||0; if(r.provider) m.provider=r.provider; } m.inTok += r.input_tokens||0; m.outTok += r.output_tokens||0; if(r.provider) m.provider=r.provider; }
const rows = ROWS.filter(r => rowMatch(r, f)); const rows = ROWS.filter(r => rowMatch(r, f) && !HIDDEN.has(r.model));
for (const r of rows){ const m=get(r.model); if(r.provider) m.provider=m.provider||r.provider; for (const r of rows){ const m=get(r.model); if(r.provider) m.provider=m.provider||r.provider;
m.findings.add(r.finding_id); m.findings.add(r.finding_id);
if (r.graded && r.is_real === true){ m.confirmed.set(r.finding_id, r.severity || ""); } if (r.graded && r.is_real === true){ m.confirmed.set(r.finding_id, r.severity || ""); }
@@ -256,7 +268,7 @@ function aggregate(f){
ptsPerMin: m.minutes>0 ? points/m.minutes : null, ptsPerMin: m.minutes>0 ? points/m.minutes : null,
ptsPerRun: m.runs>0 ? points/m.runs : null, ptsPerRun: m.runs>0 ? points/m.runs : null,
confirmedPct: findings>0 ? confirmed/findings*100 : null }; confirmedPct: findings>0 ? confirmed/findings*100 : null };
}).filter(m => m.runs>0 || m.findings>0); }).filter(m => (m.runs>0 || m.findings>0) && !HIDDEN.has(m.model));
return { models: out, rows }; return { models: out, rows };
} }
@@ -297,7 +309,16 @@ function render(){
for (const col of COLS){ for (const col of COLS){
const td = document.createElement("td"); if (col.l) td.className="l"; const td = document.createElement("td"); if (col.l) td.className="l";
const v = m[col.k]; const v = m[col.k];
if (col.k==="model"){
// model name + a hide control (× pill) — injection-safe via JS handler.
td.textContent = (v==null?"—":v) + " ";
const x = document.createElement("span");
x.className = "pill"; x.textContent = "×"; x.title = "hide this model (persists)";
x.onclick = (e)=>{ e.stopPropagation(); hideModel(m.model); };
td.appendChild(x);
} else {
td.innerHTML = col.fmt ? col.fmt(v) : (v==null?"—":v); td.innerHTML = col.fmt ? col.fmt(v) : (v==null?"—":v);
}
if ((col.k==="ptsPerMin" || col.k==="ptsPerRun" || col.k==="points") && v!=null) td.classList.add(v<0 ? "bad" : "good"); if ((col.k==="ptsPerMin" || col.k==="ptsPerRun" || col.k==="points") && v!=null) td.classList.add(v<0 ? "bad" : "good");
if (col.k==="fpPen" && v<0) td.classList.add("bad"); if (col.k==="fpPen" && v<0) td.classList.add("bad");
if (col.k==="solo" && v>0) td.classList.add("good"); if (col.k==="solo" && v>0) td.classList.add("good");
@@ -306,6 +327,24 @@ function render(){
} }
mb.appendChild(tr); mb.appendChild(tr);
} }
// hidden-models panel: click a model to restore it
const hid = document.getElementById("hidden");
if (HIDDEN.size){
hid.innerHTML = "";
const lab = document.createElement("span"); lab.textContent = "hidden ("+HIDDEN.size+"): "; hid.appendChild(lab);
for (const m of [...HIDDEN].sort()){
const p = document.createElement("span"); p.className="pill"; p.textContent = " "+m;
p.title = "show this model again"; p.style.marginRight="6px";
p.onclick = ()=> showModel(m);
hid.appendChild(p);
}
const all = document.createElement("button"); all.className="link"; all.textContent="show all";
all.onclick = showAllModels; hid.appendChild(all);
hid.style.display = "";
} else {
hid.style.display = "none"; hid.innerHTML = "";
}
const tot = models.reduce((a,m)=>({runs:a.runs+m.runs, min:a.min+m.minutes, find:a.find+m.findings, conf:a.conf+m.confirmed, pts:a.pts+m.points}), {runs:0,min:0,find:0,conf:0,pts:0}); const tot = models.reduce((a,m)=>({runs:a.runs+m.runs, min:a.min+m.minutes, find:a.find+m.findings, conf:a.conf+m.confirmed, pts:a.pts+m.points}), {runs:0,min:0,find:0,conf:0,pts:0});
document.getElementById("summary").innerHTML = document.getElementById("summary").innerHTML =
`${models.length} models · ${tot.runs} runs · ${tot.min.toFixed(0)} min · ${tot.find} findings · ${tot.conf} confirmed · ${tot.pts.toFixed(0)} pts` + `${models.length} models · ${tot.runs} runs · ${tot.min.toFixed(0)} min · ${tot.find} findings · ${tot.conf} confirmed · ${tot.pts.toFixed(0)} pts` +