Commit 96fa93e33b
Unsigned
Layout: unified · split
src/dashboard.ts +40 −6
| @@ -36,12 +36,22 @@ function installationSwitcher( | |||
| 36 | </form>`; | 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 | export interface DashboardData { | 46 | export 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 | |||
| 20 | import { | 20 | import { |
| 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, |