Commit 96fa93e33b
Unsigned
Layout: unified · split
src/dashboard.ts +40 −6
| @@ -36,12 +36,22 @@ function installationSwitcher( | ||
| 36 | 36 | </form>`; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | export const POSTURES = ["negative", "positive", "informational"] as const; | |
| 40 | export type Posture = (typeof POSTURES)[number]; | |
| 41 | ||
| 42 | export function normalizePosture(value: string | null): Posture | null { | |
| 43 | return POSTURES.includes(value as Posture) ? (value as Posture) : null; | |
| 44 | } | |
| 45 | ||
| 39 | 46 | export interface DashboardData { |
| 40 | 47 | login: string; |
| 41 | 48 | installationId: number; |
| 42 | 49 | orgLogin: string; |
| 43 | 50 | installations: InstallationOption[]; |
| 44 | 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 | 55 | rows: EvidenceRow[]; |
| 46 | 56 | exports: ExportListRow[]; |
| 47 | 57 | lastPolledAt: string | null; |
| @@ -75,6 +85,10 @@ const STYLE = ` | ||
| 75 | 85 | main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; } |
| 76 | 86 | .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } |
| 77 | 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 | 92 | .card .n { font-size: 1.6rem; font-weight: 600; } |
| 79 | 93 | .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; } |
| 80 | 94 | .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; } |
| @@ -104,10 +118,25 @@ export function renderDashboard(data: DashboardData): string { | ||
| 104 | 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 | 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 | 140 | .map( |
| 112 | 141 | (r) => `<tr> |
| 113 | 142 | <td>${esc(r.framework)}</td> |
| @@ -152,9 +181,9 @@ export function renderDashboard(data: DashboardData): string { | ||
| 152 | 181 | </header> |
| 153 | 182 | <main> |
| 154 | 183 | <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")} | |
| 158 | 187 | <div class="card"><div class="n">${repos.size}</div><div class="l">Repositories</div></div> |
| 159 | 188 | </div> |
| 160 | 189 | |
| @@ -183,7 +212,12 @@ export function renderDashboard(data: DashboardData): string { | ||
| 183 | 212 | |
| 184 | 213 | <table> |
| 185 | 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 | 221 | </table> |
| 188 | 222 | |
| 189 | 223 | <h2 class="section-title">Recent exports</h2> |
src/index.ts +3
| @@ -20,6 +20,7 @@ import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFor | ||
| 20 | 20 | import { |
| 21 | 21 | renderDashboard, |
| 22 | 22 | renderAccessReview, |
| 23 | normalizePosture, | |
| 23 | 24 | type ExportListRow, |
| 24 | 25 | type InstallationOption, |
| 25 | 26 | } from "./dashboard"; |
| @@ -483,6 +484,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { | ||
| 483 | 484 | |
| 484 | 485 | const url = new URL(request.url); |
| 485 | 486 | const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all"; |
| 487 | const posture = normalizePosture(url.searchParams.get("posture")); | |
| 486 | 488 | |
| 487 | 489 | const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([ |
| 488 | 490 | buildEvidenceRows(env.DB, session.installationId, framework), |
| @@ -510,6 +512,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { | ||
| 510 | 512 | orgLogin: orgRow?.org_login ?? "unknown", |
| 511 | 513 | installations, |
| 512 | 514 | framework, |
| 515 | posture, | |
| 513 | 516 | rows, |
| 514 | 517 | exports: exportsResult.results, |
| 515 | 518 | lastPolledAt: lastPollRow?.t ?? null, |