diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md index 713317b..3360945 100644 --- a/docs/rfc/goldpath-console.md +++ b/docs/rfc/goldpath-console.md @@ -66,6 +66,14 @@ custom-develop ON, with the same kit, the same way they add features to the back Red/overrun-predicted runs, repair-queue depths, gates awaiting four-eyes, DLQ depth — each row deep-links into its panel. Fleet browsing is one click away, never the landing page: operators open consoles to answer "is anything wrong", not to browse. + SHIPPED (U4, 2026-07-27). Three properties it must keep, because each was a decision: + (a) every number comes from the contract's own take-bounded lists — the console invents + no aggregate the API does not expose — so the screen PRINTS its scope instead of + implying completeness; (b) a surface the console cannot read (no ops role, or a call it + cannot scope) is itself a row: blindness during an incident is the most important thing + an operator can be told, and it is grouped per service so it cannot bury the estate's + real problems; (c) a surface that dies mid-read is a row too — triage never drops a + service quietly. - **D3 — Proof bar (UI is claims-are-proofs too).** Kit: component tests (vitest) on the composites (keyset table paging, verb button's refusal surface, state mapping), under a COVERAGE FLOOR that CI enforces (kit 95/90, diff --git a/ui/console/e2e/a11y.spec.ts b/ui/console/e2e/a11y.spec.ts index f929fe9..51de6ad 100644 --- a/ui/console/e2e/a11y.spec.ts +++ b/ui/console/e2e/a11y.spec.ts @@ -13,6 +13,8 @@ import { expect, test } from "@playwright/test"; const service = process.env.GOLDPATH_SERVICE_URL ?? "http://localhost:5310"; const PANELS = [ + // The landing screen is checked too: it is the one an operator sees first, at 3am. + { nav: "Today", ready: "triage-home" }, { nav: "Runs", ready: "run-console" }, { nav: "Bulk intake", ready: "bulk-panel" }, { nav: "Campaigns", ready: "campaign-panel" }, @@ -40,6 +42,7 @@ test.describe("the console is operable without a mouse or a perfect screen", () test("a confirm dialog is opened, dismissed and handed back by keyboard alone", async ({ page }) => { await page.goto(`/?base=${encodeURIComponent(service)}`); + await page.getByRole("button", { name: "Runs" }).click(); await expect(page.getByTestId("run-console")).toBeVisible(); // Opened from the KEYBOARD — the mouse is not assumed anywhere in this journey. diff --git a/ui/console/e2e/console.spec.ts b/ui/console/e2e/console.spec.ts index af840e9..33def0a 100644 --- a/ui/console/e2e/console.spec.ts +++ b/ui/console/e2e/console.spec.ts @@ -24,6 +24,7 @@ test.describe("the run console against a real Goldpath app", () => { test("triggers a job, watches the run finish, and replays its repair item", async ({ page }) => { await page.goto(`/?base=${encodeURIComponent(service)}`); + await page.getByRole("button", { name: "Runs" }).click(); // The fleet appears because the executor exists (zero-config discovery). await expect(page.getByRole("button", { name: /console-smoke/ })).toBeVisible(); @@ -41,6 +42,7 @@ test.describe("the run console against a real Goldpath app", () => { const smokeRun = page.getByRole("row", { name: /SmokeJob/ }).first(); await expect(async () => { await page.goto(`/?base=${encodeURIComponent(service)}`); + await page.getByRole("button", { name: "Runs" }).click(); await expect(smokeRun).toContainText("Completed", { timeout: 5_000 }); }).toPass({ timeout: 60_000 }); @@ -323,10 +325,16 @@ test.describe("the run console against a real Goldpath app", () => { test("behind the auth floor: every surface is NAMED as forbidden, never hidden", async ({ page }) => { await page.goto(`/?base=${encodeURIComponent(secured)}`); + // TRIAGE says it first: the console cannot see this service, which during an incident + // is the most important thing an operator can be told. + await expect(page.getByTestId("triage-home")).toContainText("surfaces on"); + await expect(page.getByTestId("triage-home")).toContainText("cannot be read"); + // The modules ARE composed here; the operator simply has no principal. Hiding them // would tell the operator this app has no admin surface, which is false. await expect(page.getByRole("button", { name: "Runs" })).toBeVisible(); await expect(page.getByRole("button", { name: "Bulk intake" })).toBeVisible(); + await page.getByRole("button", { name: "Runs" }).click(); await expect(page.getByRole("alert")).toContainText("lacks the ops role"); await expect(page.getByTestId("run-console")).toHaveCount(0); await expect(page.getByText(/No Goldpath admin surface answered here/)).toHaveCount(0); @@ -337,7 +345,9 @@ test.describe("the run console against a real Goldpath app", () => { // R1: no ambient tenant → the app refuses (400). The console must repeat that, not // silently downgrade a composed module to "absent". + await expect(page.getByTestId("triage-home")).toContainText("cannot be read"); await expect(page.getByRole("button", { name: "Runs" })).toBeVisible(); + await page.getByRole("button", { name: "Runs" }).click(); const banner = page.getByRole("alert"); await expect(banner).toContainText("composed here but refused this request"); await expect(banner).toContainText(/tenant/i); @@ -346,6 +356,7 @@ test.describe("the run console against a real Goldpath app", () => { test("a service that dies MID-SESSION is reported, not papered over", async ({ page }) => { await page.goto(`/?base=${encodeURIComponent(service)}`); + await page.getByRole("button", { name: "Runs" }).click(); await expect(page.getByTestId("run-console")).toBeVisible(); // The console discovered a healthy service; now the service stops answering. @@ -365,6 +376,10 @@ test.describe("the run console against a real Goldpath app", () => { // console reads theirs. await page.goto("/"); + // The landing screen is TODAY; the modules are one click away, never the front door. + await expect(page.getByTestId("triage-home")).toBeVisible(); + await page.getByRole("button", { name: "Runs" }).click(); + const picker = page.getByLabel(/service/i); await expect(picker).toHaveValue("open"); await expect(page.getByTestId("run-console")).toBeVisible(); @@ -384,4 +399,30 @@ test.describe("the run console against a real Goldpath app", () => { await picker.selectOption("open"); await expect(page.getByTestId("run-console")).toBeVisible(); }); + + test("triage: the first screen answers 'is anything wrong' and its rows are deep links", async ({ page }) => { + // Give the estate something to report: a batch waiting at the four-eyes gate. + const csv = "EndToEndId,Amount\nE2E-T1,10.00\nE2E-T2,20.00\n"; + const uploaded = await page.request.post(`${service}/goldpath/admin/bulk/batches/payments?fileName=triage.csv`, { + headers: { "content-type": "application/octet-stream" }, + data: csv, + }); + expect(uploaded.ok()).toBeTruthy(); + + // The validate job is real; poll the TRIAGE screen until it reports the gate. + const gate = page.getByRole("button", { name: /awaiting approval in payments/ }); + await expect(async () => { + await page.goto("/"); + await expect(page.getByTestId("triage-home")).toBeVisible({ timeout: 5_000 }); + await expect(gate).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 90_000 }); + + // The screen says WHAT it read, so the numbers cannot be mistaken for the whole truth. + await expect(page.getByTestId("triage-home")).toContainText("most recent 50 rows"); + + // A row is a deep link: it opens the panel that owns it, on the service that owns it. + await gate.click(); + await expect(page.getByTestId("bulk-panel")).toBeVisible(); + await expect(page.getByLabel(/service/i)).toHaveValue("open"); + }); }); diff --git a/ui/console/src/Console.test.tsx b/ui/console/src/Console.test.tsx deleted file mode 100644 index 4256d6a..0000000 --- a/ui/console/src/Console.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { Console } from "./Console"; - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); - -/** A service that answers ONLY the given probe roots — everything else is 404 (absent). */ -function service(present: Record) { - return (async (input: RequestInfo | URL) => { - const url = String(input); - const hit = Object.keys(present).find((route) => url.includes(route)); - if (!hit) return new Response("not found", { status: 404 }); - const status = present[hit] ?? 200; - if (status !== 200) return new Response("", { status }); - if (url.includes("/fleets")) return json([{ schedulerName: "it-cluster", jobCount: 1, nodes: [] }]); - return json([]); - }) as typeof fetch; -} - -describe("the console shell (capability discovery decides what EXISTS)", () => { - it("shows only the sections the service actually composes", async () => { - render(); - - expect(await screen.findByRole("button", { name: "Runs" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Bulk intake" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Campaigns" })).toBeNull(); // never composed - expect(screen.queryByRole("button", { name: "Archival" })).toBeNull(); - }); - - it("a forbidden capability is NAMED, not hidden — the operator learns why", async () => { - render(); - - expect(await screen.findByRole("button", { name: "Runs" })).toBeInTheDocument(); - expect(await screen.findByRole("alert")).toHaveTextContent(/lacks the ops role/i); - }); - - it("a service with no Goldpath surface says so instead of showing an empty frame", async () => { - render(); - - expect(await screen.findByText(/No Goldpath admin surface answered here/)).toBeInTheDocument(); - }); - - it("lands on the archival panel when archival is the only composed module", async () => { - render(); - - expect(await screen.findByTestId("archival-panel")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Archival" })).toHaveAttribute("aria-current", "page"); - }); - - it("lands on the notification evidence panel when notification is the only composed module", async () => { - render(); - - expect(await screen.findByTestId("notification-panel")).toBeInTheDocument(); - }); - - it("lands on the campaign governor when campaign is the only composed module", async () => { - render(); - - expect(await screen.findByTestId("campaign-panel")).toBeInTheDocument(); - }); - - it("lands on the bulk panel when bulk is the only composed module", async () => { - render(); - - expect(await screen.findByTestId("bulk-panel")).toBeInTheDocument(); - }); - - it("lands on the run console when jobs is present", async () => { - render(); - - expect(await screen.findByTestId("run-console")).toBeInTheDocument(); - }); - - it("a REFUSED capability is named with the server's reason — never hidden as absent", async () => { - // A multi-tenant app with no ambient tenant: every surface answers 400. The modules - // are composed; only this request cannot be scoped. Calling that "absent" would tell - // the operator the app does not have the module at all. - const fetcher = (async () => - new Response(JSON.stringify({ title: "Tenant could not be resolved." }), { - status: 400, - headers: { "content-type": "application/json" }, - })) as typeof fetch; - - render(); - - expect(await screen.findByRole("button", { name: "Runs" })).toBeInTheDocument(); - const banner = await screen.findByRole("alert"); - expect(banner).toHaveTextContent("Runs is composed here but refused this request"); - expect(banner).toHaveTextContent("Tenant could not be resolved."); - expect(screen.queryByText(/No Goldpath admin surface answered here/)).toBeNull(); - expect(screen.queryByTestId("run-console")).toBeNull(); - }); - - it("a forbidden capability repeats the server's words when it gave any", async () => { - const fetcher = (async () => - new Response(JSON.stringify({ message: "the 'goldpath-ops' role is required" }), { - status: 403, - headers: { "content-type": "application/json" }, - })) as typeof fetch; - - render(); - - expect(await screen.findByRole("alert")).toHaveTextContent("the 'goldpath-ops' role is required"); - }); -}); diff --git a/ui/console/src/Console.tsx b/ui/console/src/Console.tsx deleted file mode 100644 index 31e616d..0000000 --- a/ui/console/src/Console.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { AppShell, Banner } from "@goldpath/kit"; -import type { ShellNavItem } from "@goldpath/kit"; -import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient"; -import { RunConsole } from "./RunConsole"; -import { BulkPanel } from "./BulkPanel"; -import { CampaignPanel } from "./CampaignPanel"; -import { NotificationPanel } from "./NotificationPanel"; -import { ArchivalPanel } from "./ArchivalPanel"; - -export interface ConsoleProps { - /** Service root; omit for same-origin (the console is served BY the app it drives). */ - baseUrl?: string; - title?: string; - fetcher?: typeof fetch; - now?: Date; - /** The registry's other services — omitted entirely when there is only one. */ - services?: string[]; - activeService?: string; - onSelectService?: (name: string) => void; -} - -type Capabilities = Record; - -const SECTION_LABEL: Record = { - jobs: "Runs", - archival: "Archival", - bulk: "Bulk intake", - notification: "Notifications", - campaign: "Campaigns", -}; - -/** - * The console shell: capability discovery decides what EXISTS, the shell renders only - * that (console RFC §2). A module the app never composed is an absent section — never a - * dead link, never a manifest upload. A capability that is present but REFUSING — no ops - * role, or no tenant to scope the call to — says exactly that, in the server's words: - * "absent" is reserved for a module the app genuinely does not compose. - */ -export function Console({ - baseUrl, - title = "Goldpath console", - fetcher, - now, - services, - activeService, - onSelectService, -}: ConsoleProps) { - const client = useMemo(() => new AdminClient({ baseUrl, fetcher }), [baseUrl, fetcher]); - const [capabilities, setCapabilities] = useState(null); - const [section, setSection] = useState("jobs"); - const [collapsed, setCollapsed] = useState(false); - - useEffect(() => { - let live = true; - void client.discoverCapabilities().then((found) => { - if (!live) return; - setCapabilities(found); - const first = MODULES.find((module) => found[module].kind !== "absent"); - if (first) setSection(first); - }); - return () => { - live = false; - }; - }, [client]); - - const nav: ShellNavItem[] = capabilities - ? MODULES.filter((module) => capabilities[module].kind !== "absent").map((module) => ({ - id: module, - label: SECTION_LABEL[module], - onSelect: () => setSection(module), - })) - : []; - - return ( - ({ name, onSelect: () => onSelectService?.(name) }))} - activeService={activeService} - collapsed={collapsed} - onToggleCollapsed={() => setCollapsed(!collapsed)} - > - {capabilities === null &&

Discovering capabilities…

} - - {capabilities !== null && nav.length === 0 && ( -

- No Goldpath admin surface answered here — this app composes none, or the service is unreachable. -

- )} - - {capabilities?.[section].kind === "forbidden" && ( - - {SECTION_LABEL[section]} exists on this service, but your account lacks the ops role for it. - {capabilities[section].message ? ` The service said: “${capabilities[section].message}”` : ""} - - )} - - {capabilities?.[section].kind === "refused" && ( - // Composed, reachable, and REFUSING — the operator needs the server's reason - // (a multi-tenant app scopes admin calls to the ambient tenant), not a blank screen. - - {SECTION_LABEL[section]} is composed here but refused this request. - {capabilities[section].message ? ` The service said: “${capabilities[section].message}”` : ""} - - )} - - {capabilities?.[section].kind === "present" && section === "jobs" && ( - - )} - - {capabilities?.[section].kind === "present" && section === "bulk" && } - - {capabilities?.[section].kind === "present" && section === "campaign" && } - - {capabilities?.[section].kind === "present" && section === "notification" && } - - {capabilities?.[section].kind === "present" && section === "archival" && } -
- ); -} diff --git a/ui/console/src/ConsoleApp.test.tsx b/ui/console/src/ConsoleApp.test.tsx index 11201c6..7263cca 100644 --- a/ui/console/src/ConsoleApp.test.tsx +++ b/ui/console/src/ConsoleApp.test.tsx @@ -45,13 +45,22 @@ function estate(options: { registry?: unknown; registryStatus?: number } = {}) { } describe("the console across services", () => { - it("lists the registry's services and lands on the first", async () => { + it("lands on TODAY — the operator's question is 'is anything wrong', not 'what exists'", async () => { + const { fetcher } = estate(); + render(); + + expect(await screen.findByTestId("triage-home")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Today" })).toHaveAttribute("aria-current", "page"); + }); + + it("lists the registry's services and navigates the first one's modules", async () => { const { fetcher } = estate(); render(); const picker = await screen.findByLabelText(/service/i); expect(picker).toHaveValue("payments"); - expect(await screen.findByRole("button", { name: "Bulk intake" })).toBeInTheDocument(); + await userEvent.click(await screen.findByRole("button", { name: "Bulk intake" })); + expect(await screen.findByTestId("bulk-panel")).toBeInTheDocument(); }); it("switching service RE-DISCOVERS — one service's panels never appear under another's name", async () => { @@ -70,6 +79,52 @@ describe("the console across services", () => { expect(asked.some((url) => url.startsWith("https://claims.internal"))).toBe(true); }); + it("a forbidden capability is NAMED with the server's words", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("console.config.json")) return new Response("", { status: 404 }); + return new Response(JSON.stringify({ message: "the 'goldpath-ops' role is required" }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Runs" })); + expect(await screen.findByText(/lacks the ops role/)).toHaveTextContent("the 'goldpath-ops' role is required"); + }); + + it("a REFUSED capability is named too — composed, reachable, and saying no", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("console.config.json")) return new Response("", { status: 404 }); + return new Response(JSON.stringify({ title: "Tenant could not be resolved." }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Runs" })); + expect(await screen.findByText(/composed here but refused this request/)).toHaveTextContent("Tenant could not be resolved."); + }); + + it("a service with NO Goldpath surface says so instead of an empty frame", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("console.config.json")) return new Response("", { status: 404 }); + return new Response("", { status: 404 }); + }) as typeof fetch; + + render(); + + // Today still answers — it just has nothing to report from a service that composes none. + expect(await screen.findByTestId("triage-home")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Runs" })).toBeNull(); + }); + it("a single-service console shows no picker at all — nothing to choose between", async () => { const fetcher = (async (input: RequestInfo | URL) => { const url = String(input); @@ -83,6 +138,43 @@ describe("the console across services", () => { expect(screen.queryByLabelText(/service/i)).toBeNull(); }); + it("a triage row opens the panel it belongs to, on the SERVICE it belongs to", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + if (url.includes("console.config.json")) { + return json({ + services: [ + { name: "payments", adminBaseUrl: "https://payments.internal" }, + { name: "claims", adminBaseUrl: "https://claims.internal" }, + ], + }); + } + + // Only CLAIMS has something wrong: a batch waiting at its four-eyes gate. + if (url.startsWith("https://claims.internal")) { + if (url.includes("/bulk/definitions")) { + return json([{ name: "payouts", batchesByState: { Validated: 1 }, awaitingApproval: 1, oldestAwaitingApprovalSeconds: 7200 }]); + } + + if (url.includes("/bulk/batches")) return json([]); + return new Response("", { status: 404 }); + } + + if (url.includes("/jobs/fleets")) return json([]); + return new Response("", { status: 404 }); + }) as typeof fetch; + + render(); + + const row = await screen.findByRole("button", { name: /awaiting approval in payouts/ }); + await userEvent.click(row); + + // The console switched service AND section — the row is a deep link, not a label. + expect(await screen.findByTestId("bulk-panel")).toBeInTheDocument(); + expect(screen.getByLabelText(/service/i)).toHaveValue("claims"); + }); + it("a registry that failed to load is ANNOUNCED — an operator who configured four services must not silently see one", async () => { const { fetcher } = estate({ registryStatus: 500 }); render(); diff --git a/ui/console/src/ConsoleApp.tsx b/ui/console/src/ConsoleApp.tsx index 454c64b..ed3ef4e 100644 --- a/ui/console/src/ConsoleApp.tsx +++ b/ui/console/src/ConsoleApp.tsx @@ -1,28 +1,34 @@ -import { useEffect, useState } from "react"; -import { Banner } from "@goldpath/kit"; -import { Console } from "./Console"; +import { useEffect, useMemo, useState } from "react"; +import { AppShell, Banner } from "@goldpath/kit"; +import type { ShellNavItem } from "@goldpath/kit"; +import { AdminClient, type ModuleName } from "./adminClient"; +import { composedSections, SECTION_LABEL, ServicePanels, type Capabilities } from "./sections"; +import { TriageHome } from "./TriageHome"; import { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry"; export interface ConsoleAppProps { + title?: string; fetcher?: typeof fetch; /** Injected in tests; defaults to the browser's own query string. */ search?: string; now?: Date; } +/** The landing section is the estate, not a module — hence its own id. */ +const TODAY = "today"; +type Section = typeof TODAY | ModuleName; + /** - * The console, across services (console RFC §3 + D2). It owns exactly two things the - * per-service screen must not: WHICH services exist (config, not discovery) and which one - * the operator is looking at. - * - * Each service gets its own `Console` instance, keyed by name, so switching re-runs - * capability discovery from scratch — a payments service's panels must never be shown - * under a claims service's name because the shell happened to reuse the component. + * The console (console RFC §3 + D2). It owns what a single service cannot: WHICH services + * exist (config, not discovery), which one the operator is looking at, and the triage home + * that answers "is anything wrong" across all of them before any of them is opened. */ -export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) { +export function ConsoleApp({ title = "Goldpath console", fetcher, search, now }: ConsoleAppProps) { const [services, setServices] = useState(null); const [problem, setProblem] = useState<{ text: string; fellBack: boolean } | null>(null); const [active, setActive] = useState(null); + const [section, setSection] = useState
(TODAY); + const [collapsed, setCollapsed] = useState(false); useEffect(() => { let live = true; @@ -37,14 +43,64 @@ export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) { }; }, [fetcher, search]); + // One client per service, kept STABLE across renders: the capability hook keys off it, + // and a fresh client every render would re-probe every service forever. + const clients = useMemo(() => { + const map = new Map(); + for (const service of services ?? []) { + map.set(service.name, new AdminClient({ baseUrl: service.adminBaseUrl, fetcher })); + } + + return map; + }, [services, fetcher]); + + // EVERY service is probed, not just the open one: the triage home speaks for the whole + // estate, and a service whose capabilities are unknown is a service it cannot speak for. + const [discovered, setDiscovered] = useState>(new Map()); + + useEffect(() => { + let live = true; + setDiscovered(new Map()); + void Promise.all( + [...clients].map(async ([name, entry]) => [name, await entry.discoverCapabilities()] as const), + ).then((entries) => live && setDiscovered(new Map(entries))); + return () => { + live = false; + }; + }, [clients]); + + const service = (services ?? []).find((entry) => entry.name === active) ?? services?.[0] ?? SAME_ORIGIN; + const client = clients.get(service.name) ?? new AdminClient({ baseUrl: service.adminBaseUrl, fetcher }); + const capabilities = discovered.get(service.name) ?? null; + if (services === null) { return

Reading the service registry…

; } - const service = services.find((entry) => entry.name === active) ?? services[0] ?? SAME_ORIGIN; + const nav: ShellNavItem[] = [ + { id: TODAY, label: "Today", onSelect: () => setSection(TODAY) }, + ...composedSections(capabilities).map((module) => ({ + id: module, + label: SECTION_LABEL[module], + onSelect: () => setSection(module), + })), + ]; + + const open = (target: string, targetSection: ModuleName) => { + setActive(target); + setSection(targetSection); + }; return ( - <> + 1 ? services.map((entry) => ({ name: entry.name, onSelect: () => setActive(entry.name) })) : undefined} + activeService={service.name} + collapsed={collapsed} + onToggleCollapsed={() => setCollapsed(!collapsed)} + > {problem && ( // Config that failed to load is NOT a quiet fallback: an operator who configured // four services and sees one is looking at the wrong console. @@ -53,16 +109,20 @@ export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) { {problem.fellBack ? " — showing this service only." : " — the console is missing a service you configured."} )} - 1 ? services.map((entry) => entry.name) : undefined} - activeService={service.name} - onSelectService={setActive} - /> - + + {section === TODAY ? ( + ({ + name: entry.name, + client: clients.get(entry.name)!, + capabilities: discovered.get(entry.name) ?? null, + }))} + onOpen={open} + now={now} + /> + ) : ( + + )} + ); } diff --git a/ui/console/src/TriageHome.tsx b/ui/console/src/TriageHome.tsx new file mode 100644 index 0000000..3e9f445 --- /dev/null +++ b/ui/console/src/TriageHome.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import { Banner, StateBadge } from "@goldpath/kit"; +import type { AdminClient, ModuleName } from "./adminClient"; +import type { Capabilities } from "./sections"; +import { collectServiceTriage, orderTriage, TRIAGE_SCOPE, type TriageRow } from "./triage"; + +export interface TriageService { + name: string; + client: AdminClient; + capabilities: Capabilities | null; +} + +export interface TriageHomeProps { + services: TriageService[]; + /** Opens the panel a row belongs to, on the service it belongs to. */ + onOpen: (service: string, section: ModuleName) => void; + now?: Date; +} + +/** + * The operator's first screen (console RFC D2): "is anything wrong", across every service + * in the registry. Fleet browsing is one click away, never the landing page — an operator + * opens a console to answer that question, not to browse. + * + * Every row is a deep link into the panel that owns it. Nothing here is a metric the API + * does not already publish: the triage reads the same take-bounded lists an operator would + * and counts what it was given, which is why the scope is printed rather than implied. + */ +export function TriageHome({ services, onOpen, now }: TriageHomeProps) { + const [rows, setRows] = useState(null); + const [refreshToken, setRefreshToken] = useState(0); + + const ready = services.filter((service) => service.capabilities !== null); + const readyKey = ready.map((service) => service.name).join("|"); + + useEffect(() => { + if (ready.length === 0) return; + let live = true; + setRows(null); + void Promise.all( + ready.map((service) => collectServiceTriage(service.name, service.client, service.capabilities!, now)), + ).then((collected) => live && setRows(orderTriage(collected.flat()))); + return () => { + live = false; + }; + // `readyKey` is the honest dependency: the set of services whose capabilities are known. + }, [readyKey, refreshToken]); + + const waiting = services.length - ready.length; + + return ( +
+
+

Today

+ across {services.length} service{services.length === 1 ? "" : "s"} · {TRIAGE_SCOPE} + +
+ + {waiting > 0 && ( +

+ {waiting} service{waiting === 1 ? " is" : "s are"} still answering — this list will grow. +

+ )} + + {rows === null && ready.length > 0 &&

Reading every surface…

} + + {rows !== null && rows.length === 0 && ( + // The quiet morning. Said plainly, and scoped — "nothing found HERE" is not the + // same claim as "nothing is wrong", and the console must not make the second one. + + Nothing is waiting for you in {TRIAGE_SCOPE}. + + )} + + {rows !== null && rows.length > 0 && ( +
    + {rows.map((row, index) => ( +
  • + {/* The word carries the meaning; the tone only reinforces it. Colour alone + would leave the row unreadable to whoever cannot see the difference. */} + + + {row.detail} + {row.service} +
  • + ))} +
+ )} +
+ ); +} diff --git a/ui/console/src/index.ts b/ui/console/src/index.ts index 70a8aac..3e15078 100644 --- a/ui/console/src/index.ts +++ b/ui/console/src/index.ts @@ -1,5 +1,7 @@ export { AdminClient, AdminHttpError, MODULES, type AdminClientOptions, type ModuleName, type FleetInfo, type JobInfo, type RunSummary, type RunDetail, type AdminResult } from "./adminClient"; export { RunConsole, type RunConsoleProps } from "./RunConsole"; -export { Console, type ConsoleProps } from "./Console"; export { ConsoleApp, type ConsoleAppProps } from "./ConsoleApp"; +export { ServicePanels, SECTION_LABEL, composedSections, type Capabilities } from "./sections"; +export { TriageHome, type TriageHomeProps, type TriageService } from "./TriageHome"; +export { collectServiceTriage, orderTriage, TRIAGE_SCOPE, TRIAGE_TAKE, type TriageRow } from "./triage"; export { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry"; diff --git a/ui/console/src/sections.test.tsx b/ui/console/src/sections.test.tsx new file mode 100644 index 0000000..2ae1c62 --- /dev/null +++ b/ui/console/src/sections.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient"; +import { composedSections, ServicePanels, type Capabilities } from "./sections"; + +const capabilities = (over: Partial> = {}): Capabilities => + Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: "absent" }])) as Capabilities; + +const client = new AdminClient({ fetcher: (async () => new Response("[]", { status: 200 })) as typeof fetch }); + +describe("one service's panels", () => { + it("says it is still asking, rather than showing an empty screen that means nothing", () => { + render(); + + expect(screen.getByText(/Discovering capabilities…/)).toBeInTheDocument(); + }); + + it("a service that composes NOTHING says so — the console is not broken, the app has no surface", () => { + render(); + + expect(screen.getByText(/No Goldpath admin surface answered here/)).toBeInTheDocument(); + }); + + it("each composed section renders ITS panel — the switch has no wrong branch", async () => { + const answering = new AdminClient({ + fetcher: (async () => new Response("[]", { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch, + }); + + for (const [section, testId] of [ + ["jobs", "run-console"], + ["bulk", "bulk-panel"], + ["campaign", "campaign-panel"], + ["notification", "notification-panel"], + ["archival", "archival-panel"], + ] as const) { + const view = render( + , + ); + // Panels that load before they draw (bulk, the run console) need their first answer + // before the test id exists — which is itself the honest loading behaviour. + if (section === "jobs") { + expect(await view.findByText(/Discovering fleets|No runs recorded|Fleets/)).toBeTruthy(); + } else { + expect(await view.findByTestId(testId)).toBeTruthy(); + } + + view.unmount(); + } + }); + + it("composedSections keeps the standard order, whatever order the probes answered in", () => { + expect(composedSections(capabilities({ campaign: { kind: "present" }, jobs: { kind: "forbidden" } }))).toEqual([ + "jobs", + "campaign", + ]); + expect(composedSections(null)).toEqual([]); + }); +}); diff --git a/ui/console/src/sections.tsx b/ui/console/src/sections.tsx new file mode 100644 index 0000000..3eab1a1 --- /dev/null +++ b/ui/console/src/sections.tsx @@ -0,0 +1,78 @@ +import { Banner } from "@goldpath/kit"; +import { MODULES, type AdminClient, type Capability, type ModuleName } from "./adminClient"; +import { RunConsole } from "./RunConsole"; +import { BulkPanel } from "./BulkPanel"; +import { CampaignPanel } from "./CampaignPanel"; +import { NotificationPanel } from "./NotificationPanel"; +import { ArchivalPanel } from "./ArchivalPanel"; + +/** What the rail calls each module — the operator's word, not the package's. */ +export const SECTION_LABEL: Record = { + jobs: "Runs", + archival: "Archival", + bulk: "Bulk intake", + notification: "Notifications", + campaign: "Campaigns", +}; + +export type Capabilities = Record; + +/** The modules this service actually composes, in the standard order. */ +export function composedSections(capabilities: Capabilities | null): ModuleName[] { + return capabilities ? MODULES.filter((module) => capabilities[module].kind !== "absent") : []; +} + +export interface ServicePanelsProps { + client: AdminClient; + capabilities: Capabilities | null; + section: ModuleName; + now?: Date; +} + +/** + * One service's panels, without a shell around them — the shell belongs to the console as + * a whole, because the operator's first screen is the estate, not any single service. + * + * A module the app never composed is absent (no panel, no dead link). One that is present + * but REFUSING — no ops role, or no tenant to scope the call to — says exactly that, in + * the server's words. + */ +export function ServicePanels({ client, capabilities, section, now }: ServicePanelsProps) { + if (capabilities === null) { + return

Discovering capabilities…

; + } + + if (composedSections(capabilities).length === 0) { + return ( +

+ No Goldpath admin surface answered here — this app composes none, or the service is unreachable. +

+ ); + } + + const capability = capabilities[section]; + + return ( + <> + {capability.kind === "forbidden" && ( + + {SECTION_LABEL[section]} exists on this service, but your account lacks the ops role for it. + {capability.message ? ` The service said: “${capability.message}”` : ""} + + )} + + {capability.kind === "refused" && ( + + {SECTION_LABEL[section]} is composed here but refused this request. + {capability.message ? ` The service said: “${capability.message}”` : ""} + + )} + + {capability.kind === "present" && section === "jobs" && } + {capability.kind === "present" && section === "bulk" && } + {capability.kind === "present" && section === "campaign" && } + {capability.kind === "present" && section === "notification" && } + {capability.kind === "present" && section === "archival" && } + + ); +} diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts new file mode 100644 index 0000000..0dcf327 --- /dev/null +++ b/ui/console/src/triage.test.ts @@ -0,0 +1,318 @@ +import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient"; +import type { Capabilities } from "./sections"; +import { collectServiceTriage, orderTriage, type TriageRow } from "./triage"; + +const NOW = new Date("2026-07-27T12:00:00Z"); + +/** Every module present unless said otherwise — the triage reads what a service composes. */ +function capabilities(over: Partial> = {}): Capabilities { + return Object.fromEntries( + MODULES.map((module) => [module, over[module] ?? { kind: "present" }]), + ) as Capabilities; +} + +const run = (over: Record = {}) => ({ + id: "r1", + jobName: "eod-reconciliation", + status: "Completed", + startedAt: "2026-07-27T11:00:00Z", + totalChunks: 4, + completedChunks: 4, + failedChunks: 0, + itemFailures: 0, + ...over, +}); + +function client(routes: Record, fail?: (url: string) => boolean) { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (fail?.(url)) return new Response("", { status: 503 }); + // The DEEPEST match wins: `/goldpath/admin/jobs/fleets/it/runs?take=50` contains both + // "/jobs/fleets" and "/runs?", and answering it with the fleet list would quietly make + // every run assertion vacuous. + const match = Object.keys(routes) + .filter((key) => url.includes(key)) + .sort((left, right) => url.indexOf(right) - url.indexOf(left))[0]; + return new Response(JSON.stringify(match ? routes[match] : []), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return new AdminClient({ fetcher }); +} + +const headlines = (rows: TriageRow[]) => rows.map((row) => row.headline); + +describe("triage — what is wrong, read from the contract's own lists", () => { + it("a quiet estate produces NO rows: silence is the answer, not an empty metric", async () => { + const api = client({ "/jobs/fleets": [{ schedulerName: "it", jobCount: 1, nodes: [] }], "/runs?": [run()] }); + + expect(await collectServiceTriage("payments", api, capabilities(), NOW)).toEqual([]); + }); + + it("failed runs are DANGER and name the jobs", async () => { + const api = client({ + "/jobs/fleets": [{ schedulerName: "it", jobCount: 2, nodes: [] }], + "/runs?": [run({ status: "Failed", jobName: "settlement" }), run()], + }); + + const rows = await collectServiceTriage("payments", api, capabilities(), NOW); + + expect(rows[0]).toMatchObject({ tone: "danger", section: "jobs", service: "payments" }); + expect(rows[0].headline).toBe("1 failed run in it"); + expect(rows[0].detail).toBe("settlement"); + }); + + it("a run still going past its deadline is DANGER; one merely predicted to overrun is a warning", async () => { + const api = client({ + "/jobs/fleets": [{ schedulerName: "it", jobCount: 2, nodes: [] }], + "/runs?": [ + run({ id: "late", status: "Running", deadlineAt: "2026-07-27T11:30:00Z", jobName: "nightly" }), + run({ id: "soon", status: "Running", deadlineAt: "2026-07-27T13:00:00Z", predictedFinishAt: "2026-07-27T13:30:00Z", jobName: "sweep" }), + ], + }); + + const rows = await collectServiceTriage("payments", api, capabilities(), NOW); + + expect(rows.find((row) => row.tone === "danger")?.headline).toBe("1 run past the deadline in it"); + expect(rows.find((row) => row.tone === "warning")?.headline).toBe("1 run predicted to overrun in it"); + }); + + it("the repair queue is counted across the runs it was given", async () => { + const api = client({ + "/jobs/fleets": [{ schedulerName: "it", jobCount: 1, nodes: [] }], + "/runs?": [run({ itemFailures: 3 }), run({ id: "r2", itemFailures: 4 })], + }); + + const rows = await collectServiceTriage("payments", api, capabilities(), NOW); + + expect(headlines(rows)).toContain("7 items waiting in the repair queue of it"); + }); + + it("a four-eyes gate holding batches is a warning, with how long the oldest has waited", async () => { + const api = client({ + "/bulk/definitions": [{ name: "payouts", batchesByState: {}, awaitingApproval: 2, oldestAwaitingApprovalSeconds: 7200 }], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(rows[0].headline).toBe("2 batches awaiting approval in payouts"); + expect(rows[0].detail).toBe("the oldest has waited 2h"); + }); + + it("a paused campaign is reported — someone stopped it and nobody resumed it", async () => { + const api = client({ + "/campaign/?": [ + { id: "c1", name: "june-welcome", state: "Paused", failedCount: 0, succeededCount: 10, remaining: 90, tps: 2, releasedThrough: 10, enumeratedThrough: 100, enumerationComplete: true, inFlight: 0, releasedToday: 10, maxInFlight: 5, timeZoneId: "UTC", windowOpenNow: true, createdAt: "", createdBy: "" }, + ], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(headlines(rows)).toContain("june-welcome is paused"); + expect(rows[0].detail).toContain("90 items"); + }); + + it("failed notifications are DANGER; a queue that has not moved in a quarter of an hour is a warning", async () => { + const api = client({ + "/notification/templates": [ + { key: "welcome", hash: "h", byState: { Failed: 2, Sent: 8 }, oldestRequestedSeconds: 1800 }, + ], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(rows[0]).toMatchObject({ tone: "danger", headline: "2 notifications failed on welcome" }); + expect(headlines(rows)).toContain("welcome has a request waiting 30m"); + }); + + it("an archive that has not caught up is a warning", async () => { + const api = client({ + "/archival/definitions": [{ name: "policies", entries: 10, dueBacklog: 4, activeHolds: 0, chainHead: 10, purgedThrough: 0 }], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(rows[0].headline).toBe("4 aggregates due to archive in policies"); + }); + + it("an ABSENT capability contributes nothing; a REFUSING one contributes the blind spot, never a guessed number", async () => { + // The bulk surface would answer "9 awaiting approval" if we asked — but the capability + // probe was refused, so triage must not read it and must not pretend it did. + const api = client({ "/bulk/definitions": [{ name: "payouts", batchesByState: {}, awaitingApproval: 9 }] }); + + const rows = await collectServiceTriage( + "payments", + api, + capabilities({ + jobs: { kind: "absent" }, + campaign: { kind: "absent" }, + notification: { kind: "absent" }, + archival: { kind: "absent" }, + bulk: { kind: "refused", message: "no ambient tenant" }, + }), + NOW, + ); + + expect(rows).toHaveLength(1); + expect(rows[0].headline).toBe("1 surface on payments cannot be read"); + expect(rows.some((row) => row.headline.includes("9"))).toBe(false); + }); + + it("a surface that DIES mid-read becomes a row of its own — triage never drops a service quietly", async () => { + const api = client({ "/jobs/fleets": [{ schedulerName: "it", jobCount: 1, nodes: [] }] }, (url) => url.includes("/runs?")); + + const rows = await collectServiceTriage("payments", api, capabilities({ bulk: { kind: "absent" }, campaign: { kind: "absent" }, notification: { kind: "absent" }, archival: { kind: "absent" } }), NOW); + + expect(rows).toEqual([ + { + service: "payments", + section: "jobs", + tone: "danger", + headline: "the run surface could not be read", + detail: "the surface answered, then stopped — triage cannot speak for it", + }, + ]); + }); + + it("orders BLIND first, then danger, then warning — a partial picture must say so above the picture", () => { + const row = (tone: "danger" | "warning", service: string, blind?: true): TriageRow => ({ + service, + section: "jobs", + tone, + ...(blind ? { blind } : {}), + headline: `${blind ? "blind" : tone} on ${service}`, + detail: "", + }); + + expect( + headlines( + orderTriage([ + row("warning", "b"), + row("danger", "z"), + row("danger", "m", true), + row("warning", "a"), + row("danger", "a"), + ]), + ), + ).toEqual(["blind on m", "danger on a", "danger on z", "warning on a", "warning on b"]); + }); + + it("failed campaign items are reported with what did land", async () => { + const api = client({ + "/campaign/?": [ + { id: "c1", name: "june-welcome", state: "Running", failedCount: 1, succeededCount: 40, remaining: 59, tps: 2, releasedThrough: 41, enumeratedThrough: 100, enumerationComplete: true, inFlight: 1, releasedToday: 41, maxInFlight: 5, timeZoneId: "UTC", windowOpenNow: true, createdAt: "", createdBy: "" }, + ], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + // Singular, because one item is one item — the count is the operator's first read. + expect(rows[0].headline).toBe("1 failed item in june-welcome"); + expect(rows[0].detail).toBe("40 succeeded · 59 remaining"); + }); + + it("every surface reports its OWN death, not just the run surface", async () => { + for (const [surface, route, what] of [ + ["bulk", "/bulk/definitions", "the intake surface"], + ["campaign", "/campaign/?", "the campaign surface"], + ["notification", "/notification/templates", "the notification surface"], + ["archival", "/archival/definitions", "the archival surface"], + ] as const) { + const only = Object.fromEntries( + MODULES.map((module) => [module, module === surface ? { kind: "present" } : { kind: "absent" }]), + ) as Capabilities; + const api = client({}, (url) => url.includes(route.replace("?", ""))); + + const rows = await collectServiceTriage("payments", api, only, NOW); + + expect(rows).toEqual([ + { + service: "payments", + section: surface, + tone: "danger", + headline: `${what} could not be read`, + detail: "the surface answered, then stopped — triage cannot speak for it", + }, + ]); + } + }); + + it("plurals follow the count, in every row the operator reads", async () => { + const api = client({ + "/jobs/fleets": [{ schedulerName: "it", jobCount: 3, nodes: [] }], + "/runs?": [ + run({ status: "Failed" }), + run({ id: "r2", status: "Failed", jobName: "settlement" }), + run({ id: "r3", itemFailures: 1 }), + ], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ bulk: { kind: "absent" }, campaign: { kind: "absent" }, notification: { kind: "absent" }, archival: { kind: "absent" } }), NOW); + + expect(headlines(rows)).toContain("2 failed runs in it"); + expect(headlines(rows)).toContain("1 item waiting in the repair queue of it"); + }); + + it("a gate with no age still says a gate is holding — an absent number is not a zero", async () => { + const api = client({ + "/bulk/definitions": [{ name: "payouts", batchesByState: {}, awaitingApproval: 1, oldestAwaitingApprovalSeconds: null }], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(rows[0].headline).toBe("1 batch awaiting approval in payouts"); + expect(rows[0].detail).toBe("a four-eyes gate is holding them"); + }); + + it("one failed notification reads in the singular, and a young queue says nothing at all", async () => { + const api = client({ + "/notification/templates": [{ key: "welcome", hash: "h", byState: { Failed: 1 }, oldestRequestedSeconds: 60 }], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(headlines(rows)).toEqual(["1 notification failed on welcome"]); + }); + + it("one aggregate due to archive reads in the singular", async () => { + const api = client({ + "/archival/definitions": [{ name: "policies", entries: 1, dueBacklog: 1, activeHolds: 0, chainHead: 1, purgedThrough: 0 }], + }); + + const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW); + + expect(rows[0].headline).toBe("1 aggregate due to archive in policies"); + }); + + it("a service the console CANNOT READ is one row, not five — and it names the reason", async () => { + const api = client({}); + const blind = Object.fromEntries( + MODULES.map((module) => [ + module, + module === "jobs" + ? { kind: "forbidden", message: "the 'goldpath-ops' role is required" } + : { kind: "forbidden" }, + ]), + ) as Capabilities; + + const rows = await collectServiceTriage("auth-floored", api, blind, NOW); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ tone: "danger", blind: true, service: "auth-floored", section: "jobs" }); + expect(rows[0].headline).toBe("5 surfaces on auth-floored cannot be read"); + expect(rows[0].detail).toContain("the 'goldpath-ops' role is required"); + }); + + it("one unreadable surface reads in the singular, and without a message says what it can", async () => { + const rows = await collectServiceTriage( + "payments", + client({}), + capabilities({ jobs: { kind: "refused" }, bulk: { kind: "absent" }, campaign: { kind: "absent" }, notification: { kind: "absent" }, archival: { kind: "absent" } }), + NOW, + ); + + expect(rows[0].headline).toBe("1 surface on payments cannot be read"); + expect(rows[0].detail).toBe("this operator may not see them, or the request cannot be scoped"); + }); +}); diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts new file mode 100644 index 0000000..0837c26 --- /dev/null +++ b/ui/console/src/triage.ts @@ -0,0 +1,246 @@ +import { deadlineVerdict, humanizeSeconds } from "@goldpath/kit"; +import { MODULES, type AdminClient, type ModuleName } from "./adminClient"; +import type { Capabilities } from "./sections"; + +/** + * One thing worth an operator's attention. `tone` is the semantic ramp, not a priority + * number: "danger" is something that already went wrong, "warning" is something that will + * unless someone acts. + */ +export interface TriageRow { + service: string; + section: ModuleName; + tone: "danger" | "warning"; + /** + * A BLIND row is not another problem in the list — it says the list itself is + * incomplete, which qualifies everything else on the screen. That is why it sorts ahead + * even of failures (review R1: the tone alone put it below them, contradicting the very + * claim the screen makes). + */ + blind?: true; + headline: string; + detail: string; +} + +/** How much of each surface the triage reads. The contract is take-bounded everywhere. */ +export const TRIAGE_TAKE = 50; + +/** + * The caveat the screen must SAY, because the numbers below are honest only within it: + * the admin contract has no aggregate endpoint, so triage reads the same take-bounded + * lists an operator would, and counts what it was given. + */ +export const TRIAGE_SCOPE = `the most recent ${TRIAGE_TAKE} rows of each surface`; + +/** + * Collects what is wrong on ONE service. Every number comes from the frozen contract's + * own lists — the console invents no aggregate the API does not expose, and a capability + * that is absent, forbidden or refusing contributes nothing rather than a guess. + * + * A surface that FAILS to answer is itself a row: an operator whose triage silently drops + * a service is worse off than one who sees nothing at all. + */ +export async function collectServiceTriage( + service: string, + client: AdminClient, + capabilities: Capabilities, + now: Date = new Date(), +): Promise { + const rows: TriageRow[] = []; + const present = (module: ModuleName) => capabilities[module].kind === "present"; + + // A surface the console cannot READ is triage's own blind spot, and blindness during an + // incident is exactly what an operator must be told about. Grouped per service: five + // identical "forbidden" rows would bury the estate's real problems under our own. + const unreadable = MODULES.filter((module) => { + const kind = capabilities[module].kind; + return kind === "forbidden" || kind === "refused"; + }); + + if (unreadable.length > 0) { + // The filter above already established the kind; the message is all that is left to read. + const said = (capabilities[unreadable[0]] as { message?: string }).message; + rows.push({ + service, + section: unreadable[0], + tone: "danger", + blind: true, + headline: `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`, + detail: said ? `the service said: “${said}”` : "this operator may not see them, or the request cannot be scoped", + }); + } + + const unreachable = (section: ModuleName, what: string) => + rows.push({ + service, + section, + tone: "danger", + headline: `${what} could not be read`, + detail: "the surface answered, then stopped — triage cannot speak for it", + }); + + if (present("jobs")) { + try { + const fleets = await client.fleets(); + for (const fleet of fleets) { + const runs = await client.runs(fleet.schedulerName, { take: TRIAGE_TAKE }); + const failed = runs.filter((run) => run.status === "Failed"); + const overrun = runs.filter((run) => deadlineVerdict(run, now) === "overrun" && run.status !== "Failed"); + const predicted = runs.filter((run) => deadlineVerdict(run, now) === "overrun-predicted"); + const repair = runs.reduce((sum, run) => sum + run.itemFailures, 0); + + if (failed.length > 0) { + rows.push({ + service, + section: "jobs", + tone: "danger", + headline: `${failed.length} failed run${failed.length === 1 ? "" : "s"} in ${fleet.schedulerName}`, + detail: failed.map((run) => run.jobName).slice(0, 3).join(", "), + }); + } + + if (overrun.length > 0) { + rows.push({ + service, + section: "jobs", + tone: "danger", + headline: `${overrun.length} run${overrun.length === 1 ? "" : "s"} past the deadline in ${fleet.schedulerName}`, + detail: overrun.map((run) => run.jobName).slice(0, 3).join(", "), + }); + } + + if (predicted.length > 0) { + rows.push({ + service, + section: "jobs", + tone: "warning", + headline: `${predicted.length} run${predicted.length === 1 ? "" : "s"} predicted to overrun in ${fleet.schedulerName}`, + detail: predicted.map((run) => run.jobName).slice(0, 3).join(", "), + }); + } + + if (repair > 0) { + rows.push({ + service, + section: "jobs", + tone: "warning", + headline: `${repair} item${repair === 1 ? "" : "s"} waiting in the repair queue of ${fleet.schedulerName}`, + detail: "replay them from the run console once the cause is fixed", + }); + } + } + } catch { + unreachable("jobs", "the run surface"); + } + } + + if (present("bulk")) { + try { + for (const definition of await client.bulkDefinitions()) { + if (definition.awaitingApproval > 0) { + const age = definition.oldestAwaitingApprovalSeconds; + rows.push({ + service, + section: "bulk", + tone: "warning", + headline: `${definition.awaitingApproval} batch${definition.awaitingApproval === 1 ? "" : "es"} awaiting approval in ${definition.name}`, + detail: age ? `the oldest has waited ${humanizeSeconds(age)}` : "a four-eyes gate is holding them", + }); + } + } + } catch { + unreachable("bulk", "the intake surface"); + } + } + + if (present("campaign")) { + try { + for (const campaign of await client.campaigns({ take: TRIAGE_TAKE })) { + if (campaign.failedCount > 0) { + rows.push({ + service, + section: "campaign", + tone: "warning", + headline: `${campaign.failedCount} failed item${campaign.failedCount === 1 ? "" : "s"} in ${campaign.name}`, + detail: `${campaign.succeededCount} succeeded · ${campaign.remaining} remaining`, + }); + } + + if (campaign.state === "Paused") { + rows.push({ + service, + section: "campaign", + tone: "warning", + headline: `${campaign.name} is paused`, + detail: `${campaign.remaining} items are waiting for someone to resume it`, + }); + } + } + } catch { + unreachable("campaign", "the campaign surface"); + } + } + + if (present("notification")) { + try { + for (const template of await client.notificationTemplates()) { + const failed = template.byState.Failed ?? 0; + if (failed > 0) { + rows.push({ + service, + section: "notification", + tone: "danger", + headline: `${failed} notification${failed === 1 ? "" : "s"} failed on ${template.key}`, + detail: "the transport refused them — the evidence rows carry its words", + }); + } + + const waiting = template.oldestRequestedSeconds ?? 0; + if (waiting > 900) { + rows.push({ + service, + section: "notification", + tone: "warning", + headline: `${template.key} has a request waiting ${humanizeSeconds(waiting)}`, + detail: "the send job may not be running", + }); + } + } + } catch { + unreachable("notification", "the notification surface"); + } + } + + if (present("archival")) { + try { + for (const archive of await client.archiveDefinitions()) { + if (archive.dueBacklog > 0) { + rows.push({ + service, + section: "archival", + tone: "warning", + headline: `${archive.dueBacklog} aggregate${archive.dueBacklog === 1 ? "" : "s"} due to archive in ${archive.name}`, + detail: "the archive run has not caught up with them yet", + }); + } + } + } catch { + unreachable("archival", "the archival surface"); + } + } + + return rows; +} + +/** + * Blind spots first, then danger, then warning, then by service. A row that says "this + * list is incomplete" belongs above every row IN the list — otherwise the screen quietly + * invites an operator to trust a picture it knows is partial. + */ +export function orderTriage(rows: TriageRow[]): TriageRow[] { + const rank = (row: TriageRow) => (row.blind ? 0 : row.tone === "danger" ? 1 : 2); + return [...rows].sort((left, right) => { + const difference = rank(left) - rank(right); + return difference !== 0 ? difference : left.service.localeCompare(right.service); + }); +}