diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e3a83d..1ac3f23 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,22 +2,14 @@ import { AgentActivity } from "./components/AgentActivity.js"; import { ApprovalQueue } from "./components/ApprovalQueue.js"; import { ErrorBanner } from "./components/ErrorBanner.js"; import { useScanSession } from "./hooks/useScanSession.js"; -import { - classifyClientError, - recoveryFor, - type ProductErrorKind, -} from "./lib/errors.js"; +import { classifyClientError, recoveryFor, type ProductErrorKind } from "./lib/errors.js"; export function App() { const session = useScanSession(); const costs = session.activity.costs; const capped = session.activity.status === "cost_capped"; const partial = session.activity.status === "partial"; - const showBanner = - Boolean(session.error) || - capped || - partial || - Boolean(session.activity.error); + const showBanner = Boolean(session.error) || capped || partial || Boolean(session.activity.error); const kind = (session.activity.errorKind ?? (capped @@ -37,17 +29,14 @@ export function App() { ? "Some connectors failed; results below are incomplete." : ""); - const recovery = - session.activity.recovery ?? (kind ? recoveryFor(kind) : null); + const recovery = session.activity.recovery ?? (kind ? recoveryFor(kind) : null); return (
Keyring - - Access governance - + Access governance
{session.activity.scanId @@ -79,6 +68,7 @@ export function App() { /> void; @@ -121,6 +123,7 @@ export function ApprovalQueue({ const approved = ordered.filter((c) => c.status === "approved"); const pendingCount = ordered.filter((c) => c.status === "pending").length; const heldCount = ordered.filter((c) => c.status === "held").length; + const summary = countScanSummary(cards, systemIds); return (
@@ -193,6 +196,37 @@ export function ApprovalQueue({
+ {scanStatus === "completed" ? ( +
+

+ + {summary.grants} grant{summary.grants === 1 ? "" : "s"} across {summary.systems}{" "} + system{summary.systems === 1 ? "" : "s"}. + + {summary.unattributed > 0 ? ( + <> + {" "} + + {summary.unattributed} we cannot attribute to anyone. + + + ) : null} + {summary.overYearIdle > 0 ? ( + <> {summary.overYearIdle} not used in over a year. + ) : null} + {summary.irreversible > 0 ? ( + <> + {" "} + {summary.irreversible} {summary.irreversible === 1 ? "is" : "are"} irreversible to + revoke. + + ) : null} +

+
+ ) : null} {ordered.length === 0 ? ( ) : ( diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts index 8b08867..e6cf88a 100644 --- a/apps/web/src/lib/format.test.ts +++ b/apps/web/src/lib/format.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import type { ApiCard } from "../api/types.js"; -import { isUnattributed, sortCards, staleness } from "../lib/format.js"; +import { + countScanSummary, + isUnattributed, + scanSummaryText, + sortCards, + staleness, +} from "../lib/format.js"; function card(partial: Partial & Pick): ApiCard { return { @@ -62,4 +68,42 @@ describe("format helpers", () => { it("flags missing lastUsedAt as unknown staleness", () => { expect(staleness(null).level).toBe("unknown"); }); + + it("counts summary findings from cards and connected systems", () => { + const counts = countScanSummary( + [ + card({ id: "unknown", attribution: { confidence: "speculative", reasoning: "unknown" } }), + card({ + id: "old", + irreversible: true, + grant: { + ...card({ id: "old-base" }).grant, + lastUsedAt: "2024-01-01T00:00:00.000Z", + }, + }), + ], + ["github", "slack", "aws"], + new Date("2026-06-01T00:00:00.000Z"), + ); + + expect(counts).toEqual({ + grants: 2, + systems: 3, + unattributed: 1, + overYearIdle: 1, + irreversible: 1, + }); + expect(scanSummaryText(counts)).toBe( + "2 grants across 3 systems. 1 we cannot attribute to anyone. 1 not used in over a year. 1 is irreversible to revoke.", + ); + }); + + it("omits zero-category clauses from the headline", () => { + const counts = countScanSummary([card({ id: "one" })], ["github"]); + + expect(counts.unattributed).toBe(0); + expect(counts.overYearIdle).toBe(0); + expect(counts.irreversible).toBe(0); + expect(scanSummaryText(counts)).toBe("1 grant across 1 system."); + }); }); diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index a77d968..ded4ab7 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -2,6 +2,41 @@ import type { ApiCard } from "../api/types.js"; const MS_DAY = 86_400_000; +export interface ScanSummaryCounts { + grants: number; + systems: number; + unattributed: number; + overYearIdle: number; + irreversible: number; +} + +export function countScanSummary( + cards: ApiCard[], + systemIds: Iterable = cards.map((card) => card.grant.system), + now = new Date(), +): ScanSummaryCounts { + return { + grants: cards.length, + systems: new Set(systemIds).size, + unattributed: cards.filter(isUnattributed).length, + overYearIdle: cards.filter((card) => staleness(card.grant.lastUsedAt, now).level === "critical") + .length, + irreversible: cards.filter((card) => card.irreversible).length, + }; +} + +export function scanSummaryText(counts: ScanSummaryCounts): string { + const clauses = [ + `${counts.grants} grant${counts.grants === 1 ? "" : "s"} across ${counts.systems} system${counts.systems === 1 ? "" : "s"}.`, + counts.unattributed > 0 ? `${counts.unattributed} we cannot attribute to anyone.` : null, + counts.overYearIdle > 0 ? `${counts.overYearIdle} not used in over a year.` : null, + counts.irreversible > 0 + ? `${counts.irreversible} ${counts.irreversible === 1 ? "is" : "are"} irreversible to revoke.` + : null, + ].filter((clause): clause is string => clause !== null); + return clauses.join(" "); +} + export function principalLabel(card: ApiCard): string { const ids = card.grant.principal.identifiers; if (ids.length === 0) return "Unknown principal"; @@ -30,7 +65,10 @@ export function formatWhen(iso: string | null | undefined): string { }); } -export function staleness(iso: string | null | undefined, now = new Date()): { +export function staleness( + iso: string | null | undefined, + now = new Date(), +): { label: string; level: "ok" | "cool" | "stale" | "critical" | "unknown"; days: number | null; diff --git a/scripts/demo.ts b/scripts/demo.ts index a03002c..5df17e4 100644 --- a/scripts/demo.ts +++ b/scripts/demo.ts @@ -102,6 +102,8 @@ async function main(): Promise { process.env.KEYRING_PGLITE_PATH = pglitePath; process.env.KEYRING_SCAN_DRIVER = "replay"; process.env.KEYRING_EXECUTE_DRY_RUN = "1"; + process.env.KEYRING_REPLAY_SPEED = process.env.KEYRING_REPLAY_SPEED ?? "20"; + process.env.KEYRING_REPLAY_MAX_GAP_MS = process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000"; // Clear DATABASE_URL so demo never requires Docker Postgres delete process.env.DATABASE_URL; @@ -154,8 +156,6 @@ async function main(): Promise { VITE_SCAN_DRIVER: "replay", VITE_API_BASE_URL: "", VITE_API_PORT: String(apiPort), - KEYRING_REPLAY_SPEED: process.env.KEYRING_REPLAY_SPEED ?? "20", - KEYRING_REPLAY_MAX_GAP_MS: process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000", }, stdio: ["ignore", "pipe", "pipe"], },