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(
3636 </form>`;
3737}
3838
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
3946export interface DashboardData {
4047 login: string;
4148 installationId: number;
4249 orgLogin: string;
4350 installations: InstallationOption[];
4451 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;
4555 rows: EvidenceRow[];
4656 exports: ExportListRow[];
4757 lastPolledAt: string | null;
@@ -75,6 +85,10 @@ const STYLE = `
7585 main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
7686 .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
7787 .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: " ✕"; }
7892 .card .n { font-size: 1.6rem; font-weight: 600; }
7993 .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
8094 .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; }
@@ -104,10 +118,25 @@ export function renderDashboard(data: DashboardData): string {
104118 if (r.repo) repos.add(r.repo);
105119 }
106120
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
107127 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;
109138
110 const evidenceRows = data.rows
139 const evidenceRows = visibleRows
111140 .map(
112141 (r) => `<tr>
113142 <td>${esc(r.framework)}</td>
@@ -152,9 +181,9 @@ export function renderDashboard(data: DashboardData): string {
152181 </header>
153182 <main>
154183 <div class="cards">
155 <div class="card"><div class="n negative">${counts.negative}</div><div class="l">Gaps</div></div>
156 <div class="card"><div class="n positive">${counts.positive}</div><div class="l">Satisfied</div></div>
157 <div class="card"><div class="n informational">${counts.informational}</div><div class="l">Informational</div></div>
184 ${postureCard("negative", "Gaps")}
185 ${postureCard("positive", "Satisfied")}
186 ${postureCard("informational", "Informational")}
158187 <div class="card"><div class="n">${repos.size}</div><div class="l">Repositories</div></div>
159188 </div>
160189
@@ -183,7 +212,12 @@ export function renderDashboard(data: DashboardData): string {
183212
184213 <table>
185214 <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>
187221 </table>
188222
189223 <h2 class="section-title">Recent exports</h2>
src/index.ts +3
@@ -20,6 +20,7 @@ import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFor
2020import {
2121 renderDashboard,
2222 renderAccessReview,
23 normalizePosture,
2324 type ExportListRow,
2425 type InstallationOption,
2526} from "./dashboard";
@@ -483,6 +484,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
483484
484485 const url = new URL(request.url);
485486 const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all";
487 const posture = normalizePosture(url.searchParams.get("posture"));
486488
487489 const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([
488490 buildEvidenceRows(env.DB, session.installationId, framework),
@@ -510,6 +512,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
510512 orgLogin: orgRow?.org_login ?? "unknown",
511513 installations,
512514 framework,
515 posture,
513516 rows,
514517 exports: exportsResult.results,
515518 lastPolledAt: lastPollRow?.t ?? null,