Commit 96fa93e33b

96fa93e33bcfe4a0982661cb6365913a92013f28

parent: cadec30c7d

Unsigned

cmc <hello@cleberg.net> · 2026-08-19 23:59 UTC

Make the dashboard posture cards filter the evidence table

The Gaps / Satisfied / Informational cards are now links that narrow the
table below to that posture via ?posture=, and clicking the active card
clears the filter. Counts stay computed over the whole framework view so
the other cards remain clickable while a filter is applied, and the
framework tabs carry the posture along.

Closes #28

Layout: unified · split

src/dashboard.ts +40 −6
@@ -36,12 +36,22 @@ function installationSwitcher(
36 </form>`; 36 </form>`;
37} 37}
38 38
39export const POSTURES = ["negative", "positive", "informational"] as const;
40export type Posture = (typeof POSTURES)[number];
41
42export function normalizePosture(value: string | null): Posture | null {
43 return POSTURES.includes(value as Posture) ? (value as Posture) : null;
44}
45
39export interface DashboardData { 46export interface DashboardData {
40 login: string; 47 login: string;
41 installationId: number; 48 installationId: number;
42 orgLogin: string; 49 orgLogin: string;
43 installations: InstallationOption[]; 50 installations: InstallationOption[];
44 framework: Framework; 51 framework: Framework;
52 // Null = show every posture; otherwise the table is narrowed to the one
53 // whose card the user clicked.
54 posture: Posture | null;
45 rows: EvidenceRow[]; 55 rows: EvidenceRow[];
46 exports: ExportListRow[]; 56 exports: ExportListRow[];
47 lastPolledAt: string | null; 57 lastPolledAt: string | null;
@@ -75,6 +85,10 @@ const STYLE = `
75 main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; } 85 main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
76 .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } 86 .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
77 .card { flex: 1 1 120px; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; padding: 0.9rem 1rem; } 87 .card { flex: 1 1 120px; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; padding: 0.9rem 1rem; }
88 a.card { display: block; color: inherit; text-decoration: none; }
89 a.card:hover { border-color: #0055dc; }
90 a.card.active { border-color: #0055dc; box-shadow: inset 0 0 0 1px #0055dc; }
91 a.card.active .l::after { content: " ✕"; }
78 .card .n { font-size: 1.6rem; font-weight: 600; } 92 .card .n { font-size: 1.6rem; font-weight: 600; }
79 .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; } 93 .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
80 .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; } 94 .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; }
@@ -104,10 +118,25 @@ export function renderDashboard(data: DashboardData): string {
104 if (r.repo) repos.add(r.repo); 118 if (r.repo) repos.add(r.repo);
105 } 119 }
106 120
121 // Cards count the whole framework view, so the numbers stay stable while a
122 // posture filter is applied — otherwise clicking "Gaps" would zero the other
123 // two cards and there would be nothing left to click.
124 const href = (framework: Framework, posture: Posture | null) =>
125 `/?framework=${framework}${posture ? `&posture=${posture}` : ""}`;
126
107 const frameworkTab = (value: Framework, label: string) => 127 const frameworkTab = (value: Framework, label: string) =>
108 `<a href="/?framework=${value}" class="${data.framework === value ? "active" : ""}">${label}</a>`; 128 `<a href="${href(value, data.posture)}" class="${data.framework === value ? "active" : ""}">${label}</a>`;
129
130 // Clicking the active card clears the filter rather than reapplying it.
131 const postureCard = (posture: Posture, label: string) => {
132 const active = data.posture === posture;
133 return `<a class="card${active ? " active" : ""}" href="${href(data.framework, active ? null : posture)}"
134 aria-pressed="${active}"><div class="n ${posture}">${counts[posture]}</div><div class="l">${label}</div></a>`;
135 };
136
137 const visibleRows = data.posture ? data.rows.filter((r) => r.posture === data.posture) : data.rows;
109 138
110 const evidenceRows = data.rows 139 const evidenceRows = visibleRows
111 .map( 140 .map(
112 (r) => `<tr> 141 (r) => `<tr>
113 <td>${esc(r.framework)}</td> 142 <td>${esc(r.framework)}</td>
@@ -152,9 +181,9 @@ export function renderDashboard(data: DashboardData): string {
152 </header> 181 </header>
153 <main> 182 <main>
154 <div class="cards"> 183 <div class="cards">
155 <div class="card"><div class="n negative">${counts.negative}</div><div class="l">Gaps</div></div> 184 ${postureCard("negative", "Gaps")}
156 <div class="card"><div class="n positive">${counts.positive}</div><div class="l">Satisfied</div></div> 185 ${postureCard("positive", "Satisfied")}
157 <div class="card"><div class="n informational">${counts.informational}</div><div class="l">Informational</div></div> 186 ${postureCard("informational", "Informational")}
158 <div class="card"><div class="n">${repos.size}</div><div class="l">Repositories</div></div> 187 <div class="card"><div class="n">${repos.size}</div><div class="l">Repositories</div></div>
159 </div> 188 </div>
160 189
@@ -183,7 +212,12 @@ export function renderDashboard(data: DashboardData): string {
183 212
184 <table> 213 <table>
185 <thead><tr><th>Framework</th><th>Control</th><th>Posture</th><th>Repo / Subject</th><th>Resource</th><th>Status</th></tr></thead> 214 <thead><tr><th>Framework</th><th>Control</th><th>Posture</th><th>Repo / Subject</th><th>Resource</th><th>Status</th></tr></thead>
186 <tbody>${evidenceRows || `<tr><td colspan="6" class="muted">No evidence yet.</td></tr>`}</tbody> 215 <tbody>${
216 evidenceRows ||
217 `<tr><td colspan="6" class="muted">${
218 data.posture ? `No ${esc(data.posture)} evidence in this view.` : "No evidence yet."
219 }</td></tr>`
220 }</tbody>
187 </table> 221 </table>
188 222
189 <h2 class="section-title">Recent exports</h2> 223 <h2 class="section-title">Recent exports</h2>
src/index.ts +3
@@ -20,6 +20,7 @@ import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFor
20import { 20import {
21 renderDashboard, 21 renderDashboard,
22 renderAccessReview, 22 renderAccessReview,
23 normalizePosture,
23 type ExportListRow, 24 type ExportListRow,
24 type InstallationOption, 25 type InstallationOption,
25} from "./dashboard"; 26} from "./dashboard";
@@ -483,6 +484,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
483 484
484 const url = new URL(request.url); 485 const url = new URL(request.url);
485 const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all"; 486 const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all";
487 const posture = normalizePosture(url.searchParams.get("posture"));
486 488
487 const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([ 489 const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([
488 buildEvidenceRows(env.DB, session.installationId, framework), 490 buildEvidenceRows(env.DB, session.installationId, framework),
@@ -510,6 +512,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
510 orgLogin: orgRow?.org_login ?? "unknown", 512 orgLogin: orgRow?.org_login ?? "unknown",
511 installations, 513 installations,
512 framework, 514 framework,
515 posture,
513 rows, 516 rows,
514 exports: exportsResult.results, 517 exports: exportsResult.results,
515 lastPolledAt: lastPollRow?.t ?? null, 518 lastPolledAt: lastPollRow?.t ?? null,