From e3b390e7adf2a66b6c8753a8e1604ae174d2a939 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sat, 8 Aug 2026 17:07:09 +0200 Subject: [PATCH] feat(cache): add browser projection overview --- .../greenfield-rewrite/progress.md | 40 +- .../api/useRealtimeQueryInvalidation.ts | 18 +- greenfield/src/browser/application.test.tsx | 20 +- greenfield/src/browser/cache/CacheBrowser.tsx | 134 +++++ .../src/browser/cache/CacheEntryDetail.tsx | 299 +++++++++++ .../browser/cache/CacheStatusTable.test.tsx | 114 ++++ .../src/browser/cache/CacheStatusTable.tsx | 122 +++++ .../src/browser/cache/SystemHostCard.test.tsx | 95 ++++ .../src/browser/cache/SystemHostCard.tsx | 107 ++++ .../src/browser/cache/cacheMutations.test.tsx | 485 ++++++++++++++++++ .../src/browser/cache/cacheMutations.ts | 107 ++++ .../browser/cache/cachePresentation.test.ts | 46 ++ .../src/browser/cache/cachePresentation.ts | 97 ++++ .../src/browser/cache/cacheQueries.test.ts | 346 +++++++++++++ greenfield/src/browser/cache/cacheQueries.ts | 101 ++++ .../useCacheRealtimeInvalidation.test.tsx | 205 ++++++++ .../cache/useCacheRealtimeInvalidation.ts | 76 +++ .../browser/overview/OverviewRoute.test.tsx | 400 +++++++++++++++ .../src/browser/overview/OverviewRoute.tsx | 18 + .../src/browser/routes/overview.lazy.tsx | 45 +- greenfield/src/browser/ui/EmptyState.tsx | 8 +- greenfield/src/browser/ui/PageState.tsx | 9 +- .../parity/fixtures/legacy-endpoints.json | 12 +- .../src/test/parity/parityInventory.test.ts | 4 +- 24 files changed, 2834 insertions(+), 74 deletions(-) create mode 100644 greenfield/src/browser/cache/CacheBrowser.tsx create mode 100644 greenfield/src/browser/cache/CacheEntryDetail.tsx create mode 100644 greenfield/src/browser/cache/CacheStatusTable.test.tsx create mode 100644 greenfield/src/browser/cache/CacheStatusTable.tsx create mode 100644 greenfield/src/browser/cache/SystemHostCard.test.tsx create mode 100644 greenfield/src/browser/cache/SystemHostCard.tsx create mode 100644 greenfield/src/browser/cache/cacheMutations.test.tsx create mode 100644 greenfield/src/browser/cache/cacheMutations.ts create mode 100644 greenfield/src/browser/cache/cachePresentation.test.ts create mode 100644 greenfield/src/browser/cache/cachePresentation.ts create mode 100644 greenfield/src/browser/cache/cacheQueries.test.ts create mode 100644 greenfield/src/browser/cache/cacheQueries.ts create mode 100644 greenfield/src/browser/cache/useCacheRealtimeInvalidation.test.tsx create mode 100644 greenfield/src/browser/cache/useCacheRealtimeInvalidation.ts create mode 100644 greenfield/src/browser/overview/OverviewRoute.test.tsx create mode 100644 greenfield/src/browser/overview/OverviewRoute.tsx diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 6177a87ee..50ad4eef9 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -7,15 +7,15 @@ This matrix is the living phase status. Update it in the same change that materially advances or closes a phase; dated entries below provide the evidence, not a second status source. -| Phase | Status | Current evidence and remaining gate | -| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | -| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | -| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented; report, incident, and global notification browser state are also complete. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, and the first claim-fenced `system.host` cache provider are implemented. OpenClaw cron, overview, cache browser consumption, and metrics remain open. | -| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | -| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | -| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | +| Phase | Status | Current evidence and remaining gate | +| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | +| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | +| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | +| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented; report, incident, and global notification browser state are also complete. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, the first claim-fenced `system.host` cache provider, and its cache-browser/overview foundation are implemented. Metrics and the remaining overview composition stay open; OpenClaw cron belongs to Phase 4. | +| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | +| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | +| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started @@ -997,3 +997,25 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o pump locally; worker-originated cache outbox rows are discovered through its bounded adaptive cross-process polling. Cache browser consumption, metrics, overview, persistent OpenClaw cron, and the remaining Phase 3 exit gates stay open. + +### 2026-08-08 — Phase 3 cache browser and overview foundation + +- The authenticated root route now consumes the bounded cache status projection and loads exact + entry detail only after selection. Its inventory preserves `totalCount` and `truncated`, keeps + freshness separate from the last attempt result, virtualizes larger snapshots, and renders only + the reviewed `system.host` payload rather than exposing generic cache payloads or metadata. +- TanStack Query owns the remote state. Status polling advances derived freshness every 30 seconds, + while `cache.entries` events coalesce precise status and selected-entry invalidation through the + shared realtime fallback and terminal-resync behavior. Transient refresh failures retain the + last validated snapshot and expose only fixed, non-sensitive warnings. +- Manual refresh is available only for providers that advertise it. Lost-response retries retain + one caller/auth-generation-scoped idempotency key per cache key, and confirmed enqueue results + repair job projections before best-effort cache, job, and schedule invalidation. The UI presents + the validated run state and links to the exact run; exact cache state remains authoritative for + clearing accepted-run feedback. +- Route and component coverage locks auth gating, lazy exact reads, truncation disclosure, + accessibility, safe payload validation, separate freshness/attempt presentation, realtime + invalidation, and retry-safe refresh. No contract, server, database, or migration surface changes + in this slice. Root-route parity remains `planned`: metrics and the remaining Phase 3 overview + composition are still open. Parity bookkeeping assigns OpenClaw cron and `cache.getHeartbeat` to + Phase 4 with their persistent authenticated Gateway dependency. diff --git a/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts b/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts index cb5392589..76294289e 100644 --- a/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts +++ b/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts @@ -1,5 +1,5 @@ import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useDashboardRealtimeHub } from "./realtimeContextValue.ts"; import type { DashboardRealtimeTopic } from "./realtimeHub.ts"; @@ -23,6 +23,11 @@ export function useRealtimeQueryInvalidation({ }: RealtimeQueryInvalidationOptions): void { const hub = useDashboardRealtimeHub(); const queryClient = useQueryClient(); + const refreshQueriesReference = useRef(refreshQueries); + + useEffect(() => { + refreshQueriesReference.current = refreshQueries; + }, [refreshQueries]); useEffect(() => { let disposed = false; @@ -35,7 +40,7 @@ export function useRealtimeQueryInvalidation({ if (disposed) return; refreshInFlight = true; try { - await refreshQueries(queryClient); + await refreshQueriesReference.current(queryClient); } catch { // Realtime refresh is best-effort; cached data remains available. } finally { @@ -76,12 +81,5 @@ export function useRealtimeQueryInvalidation({ if (refreshTimer !== undefined) clearTimeout(refreshTimer); if (fallbackTimer !== undefined) clearInterval(fallbackTimer); }; - }, [ - fallbackRefreshIntervalMs, - hub, - queryClient, - refreshDelayMs, - refreshQueries, - topic, - ]); + }, [fallbackRefreshIntervalMs, hub, queryClient, refreshDelayMs, topic]); } diff --git a/greenfield/src/browser/application.test.tsx b/greenfield/src/browser/application.test.tsx index 1f7ef6417..f1f6e5a71 100644 --- a/greenfield/src/browser/application.test.tsx +++ b/greenfield/src/browser/application.test.tsx @@ -19,7 +19,7 @@ const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ }); describe("Dashboard browser application", () => { - test("renders the overview and owns throttled authenticated activity", async () => { + test("renders the overview cache foundation and owns authenticated activity", async () => { const timestampMs = Date.now(); const queryClient = createDashboardQueryClient(); const router = createDashboardRouter( @@ -27,6 +27,7 @@ describe("Dashboard browser application", () => { ); let touchCalls = 0; let notificationCalls = 0; + let cacheStatusCalls = 0; const trpcClient = createDashboardTrpcClient({ mutation(path, input) { expect(path).toBe("auth.touch"); @@ -35,6 +36,16 @@ describe("Dashboard browser application", () => { return Promise.resolve({ lastSeenAtMs: timestampMs }); }, query(path, input) { + if (path === "cache.getStatus") { + expect(input).toEqual({}); + cacheStatusCalls += 1; + return Promise.resolve({ + entries: [], + generatedAtMs: timestampMs, + totalCount: 0, + truncated: false, + }); + } if (path === "notifications.list") { expect(input).toEqual({ limit: 100 }); notificationCalls += 1; @@ -89,11 +100,14 @@ describe("Dashboard browser application", () => { expect( screen.getByRole("link", { name: "Skip to content" }).getAttribute("href") ).toBe("#dashboard-content"); + expect(screen.getByRole("heading", { level: 2, name: "Cache" })).toBeTruthy(); expect( - screen.getByRole("status", { name: "Application status" }).textContent - ).toContain("Application shell ready"); + screen.getByRole("heading", { level: 3, name: "No cache attempts yet" }) + ).toBeTruthy(); + expect(screen.queryByText("Select a cache entry")).toBeNull(); await waitFor(() => expect(touchCalls).toBe(1)); await waitFor(() => expect(notificationCalls).toBe(1)); + await waitFor(() => expect(cacheStatusCalls).toBe(1)); expect(queryClient.getQueryData(authStatusQueryKey)).toMatchObject({ session: { lastSeenAtMs: timestampMs }, state: "authenticated", diff --git a/greenfield/src/browser/cache/CacheBrowser.tsx b/greenfield/src/browser/cache/CacheBrowser.tsx new file mode 100644 index 000000000..618436ccc --- /dev/null +++ b/greenfield/src/browser/cache/CacheBrowser.tsx @@ -0,0 +1,134 @@ +import { useQuery } from "@tanstack/react-query"; +import { DatabaseZap } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { cn } from "../lib/classNames.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Text } from "../ui/Text.tsx"; +import { CacheEntryDetail } from "./CacheEntryDetail.tsx"; +import { cacheBrowserFailureMessage } from "./cachePresentation.ts"; +import { cacheStatusQueryOptions } from "./cacheQueries.ts"; +import { CacheStatusTable } from "./CacheStatusTable.tsx"; +import { useCacheRealtimeInvalidation } from "./useCacheRealtimeInvalidation.ts"; + +/** @returns Bounded cache inventory, exact lazy detail, and realtime refresh behavior. */ +export function CacheBrowser() { + useCacheRealtimeInvalidation(); + const client = useDashboardTrpcClient(); + const query = useQuery(cacheStatusQueryOptions(client)); + const [selectedKey, setSelectedKey] = useState(); + const hasSelectableEntries = (query.data?.entries.length ?? 0) > 0; + let inventory: ReactNode; + + if (query.isPending && query.data === undefined) { + inventory = ( + + + + ); + } else if (query.data === undefined) { + inventory = ( + void query.refetch()} + retryBusy={query.isFetching} + status="error" + title="Cache status unavailable" + /> + ); + } else if (query.data.entries.length === 0 && query.data.totalCount === 0) { + inventory = ( + + ); + } else if (query.data.entries.length === 0) { + inventory = ( + void query.refetch()} + retryBusy={query.isFetching} + status="error" + title="Cache snapshot incomplete" + /> + ); + } else { + inventory = ( + + + + ); + } + + return ( +
+
+
+ + Cache + + + Last-known-good provider projections and their independent refresh + attempt state. + +
+ {query.data !== undefined && ( +
+ + {query.data.truncated + ? `Showing ${query.data.entries.length} of ${query.data.totalCount}` + : `${query.data.totalCount} ${query.data.totalCount === 1 ? "entry" : "entries"}`} + + + Snapshot {formatDashboardDateTime(query.data.generatedAtMs)} + +
+ )} +
+ {query.error !== null && query.data !== undefined && ( + + )} +
+ {inventory} + {hasSelectableEntries && + (selectedKey === undefined ? ( + + ) : ( + + ))} +
+
+ ); +} diff --git a/greenfield/src/browser/cache/CacheEntryDetail.tsx b/greenfield/src/browser/cache/CacheEntryDetail.tsx new file mode 100644 index 000000000..87d6485ca --- /dev/null +++ b/greenfield/src/browser/cache/CacheEntryDetail.tsx @@ -0,0 +1,299 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { DatabaseZap, RefreshCw } from "lucide-react"; +import { useEffect } from "react"; + +import type { CacheEntry } from "../../contracts/cache.ts"; +import type { JobRunState } from "../../contracts/jobModel.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { buttonClassNames } from "../ui/buttonStyles.ts"; +import { Card } from "../ui/Card.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Text } from "../ui/Text.tsx"; +import { useRefreshCacheEntryMutation } from "./cacheMutations.ts"; +import { + cacheAttemptVariant, + cacheBrowserFailureMessage, + cacheFreshnessVariant, + formatCacheDuration, +} from "./cachePresentation.ts"; +import { cacheEntryQueryOptions } from "./cacheQueries.ts"; +import { SystemHostCard } from "./SystemHostCard.tsx"; + +interface CacheTimestampProps { + readonly label: string; + readonly value?: number; +} + +function CacheTimestamp({ label, value }: CacheTimestampProps) { + return ( +
+
+ {label} +
+
+ {value === undefined ? ( + "—" + ) : ( + + )} +
+
+ ); +} + +function CacheProjection({ entry }: { readonly entry: CacheEntry }) { + if (entry.payload === undefined) { + return ( + + ); + } + if (entry.key === "system.host") return ; + return ( + + Projection available + + This provider has no reviewed browser renderer yet. Its bounded payload is + retained server-side and is not displayed generically. + + + ); +} + +interface CacheEntryDetailProps { + readonly cacheKey: string; +} + +function cacheRefreshRunFeedback(state: JobRunState): { + readonly message: string; + readonly variant: "error" | "info" | "success"; +} { + switch (state) { + case "queued": { + return { + message: + "Refresh queued. Cache data changes only after the worker commits the provider attempt.", + variant: "info", + }; + } + case "running": { + return { + message: + "Refresh run is in progress. Cache realtime will reconcile the projection after the provider attempt.", + variant: "info", + }; + } + case "succeeded": { + return { + message: + "Refresh run succeeded. The cache projection is being reconciled.", + variant: "success", + }; + } + case "cancelled": { + return { + message: "Refresh run was cancelled. Open the run for reviewed details.", + variant: "error", + }; + } + case "failed": { + return { + message: "Refresh run failed. Open the run for reviewed details.", + variant: "error", + }; + } + case "timed-out": { + return { + message: "Refresh run timed out. Open the run for reviewed details.", + variant: "error", + }; + } + } +} + +/** @returns One exact cache entry, reviewed payload projection, and refresh-run control. */ +export function CacheEntryDetail({ cacheKey }: CacheEntryDetailProps) { + const client = useDashboardTrpcClient(); + const detail = useQuery(cacheEntryQueryOptions(client, cacheKey)); + const refresh = useRefreshCacheEntryMutation(); + const detailKey = detail.data?.key; + const detailRunId = detail.data?.lastAttemptRunId; + const refreshKey = refresh.variables?.key; + const refreshRunId = refresh.data?.id; + const resetRefresh = refresh.reset; + + useEffect(() => { + if ( + detailKey === refreshKey && + detailRunId !== undefined && + detailRunId === refreshRunId + ) { + resetRefresh(); + } + }, [detailKey, detailRunId, refreshKey, refreshRunId, resetRefresh]); + + if (detail.isPending && detail.data === undefined) { + return ( + + + + ); + } + if (detail.data === undefined) { + return ( + void detail.refetch()} + retryBusy={detail.isFetching} + status="error" + title="Cache entry unavailable" + /> + ); + } + + const entry = detail.data; + const refreshRun = + refresh.variables?.key === entry.key && + refresh.data?.id !== entry.lastAttemptRunId + ? refresh.data + : undefined; + const refreshRunFeedback = + refreshRun === undefined ? undefined : cacheRefreshRunFeedback(refreshRun.state); + return ( +
+ +
+
+ + Exact cache entry + + + {entry.key} + + + {entry.source ?? "No successful source"} ·{" "} + {entry.schemaId ?? "No schema"} + +
+
+ + {entry.freshness} + + + last attempt {entry.lastAttemptStatus} + +
+
+ + + + {refreshRunFeedback !== undefined && ( + + )} +
+ + + +
+
+ Attempt +
+
+ #{entry.lastAttemptNumber} ·{" "} + {formatCacheDuration(entry.lastAttemptDurationMs)} +
+
+
+
+ Consecutive failures +
+
+ {entry.consecutiveFailures} +
+
+
+
+ Attempt run +
+
+ + {entry.lastAttemptRunId} + +
+
+
+
+ {entry.manualRunAvailable ? ( + + ) : ( + Manual refresh is unavailable. + )} + {refreshRun !== undefined && ( + + Open refresh run + + )} +
+
+
+ +
+
+ ); +} diff --git a/greenfield/src/browser/cache/CacheStatusTable.test.tsx b/greenfield/src/browser/cache/CacheStatusTable.test.tsx new file mode 100644 index 000000000..bb52dbf3d --- /dev/null +++ b/greenfield/src/browser/cache/CacheStatusTable.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, jest, test } from "bun:test"; + +import type { CacheEntryStatus } from "../../contracts/cache.ts"; +import { CacheStatusTable } from "./CacheStatusTable.tsx"; + +const { render, screen } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const timestampMs = 1_800_000_000_000; + +function status( + key: string, + overrides: Partial = {} +): CacheEntryStatus { + return { + consecutiveFailures: 0, + expiresAtMs: timestampMs + 86_400_000, + freshness: "fresh", + key, + lastAttemptAtMs: timestampMs, + lastAttemptDurationMs: 120, + lastAttemptNumber: 1, + lastAttemptRunId: "019fe000-0000-7000-8000-000000000001", + lastAttemptStatus: "succeeded", + lastSuccessAtMs: timestampMs, + manualRunAvailable: true, + metadata: {}, + schemaId: "system.host.v1", + source: "system.host", + updatedAtMs: timestampMs, + ...overrides, + }; +} + +describe("CacheStatusTable", () => { + test("shows freshness separately from the latest failed attempt", async () => { + const selected: string[] = []; + render( + selected.push(key)} + /> + ); + expect(screen.getByText("fresh")).toBeTruthy(); + expect(screen.getByText("failed")).toBeTruthy(); + expect(screen.getByText("Available")).toBeTruthy(); + + await userEvent + .setup() + .click(screen.getByRole("button", { name: "system.host" })); + expect(selected).toEqual(["system.host"]); + }); + + test("marks the selected cache key and keeps the scroll table keyboard reachable", () => { + render( + { + throw new Error("Unexpected cache selection"); + }} + selectedKey="system.host" + /> + ); + expect( + screen + .getByRole("button", { name: "system.host" }) + .getAttribute("aria-current") + ).toBe("true"); + expect(screen.getByRole("button", { name: "system.host" })).toHaveClass( + "min-h-8", + "underline" + ); + expect(screen.getByRole("region", { name: "Cache entries" }).tabIndex).toBe(0); + expect(screen.queryAllByText("Unavailable")).toHaveLength(0); + }); + + test("virtualizes a large bounded snapshot without removing table semantics", () => { + render( + + status(`provider.${index.toString().padStart(3, "0")}`) + )} + onSelect={jest.fn()} + /> + ); + const table = screen.getByRole("table", { name: "Cache entries" }); + expect(table.getAttribute("aria-rowcount")).toBe("51"); + expect(table.querySelector("td[height]")).toBeTruthy(); + expect(screen.getByRole("region", { name: "Cache entries" }).tabIndex).toBe(0); + expect(screen.queryAllByRole("button").length).toBeLessThan(50); + }); +}); diff --git a/greenfield/src/browser/cache/CacheStatusTable.tsx b/greenfield/src/browser/cache/CacheStatusTable.tsx new file mode 100644 index 000000000..9274c59f2 --- /dev/null +++ b/greenfield/src/browser/cache/CacheStatusTable.tsx @@ -0,0 +1,122 @@ +import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table"; + +import type { CacheEntryStatus } from "../../contracts/cache.ts"; +import { cn } from "../lib/classNames.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Badge } from "../ui/Badge.tsx"; +import { DataTable } from "../ui/DataTable.tsx"; +import { Text } from "../ui/Text.tsx"; +import { Virtualizer, type VirtualizerRenderState } from "../ui/Virtualizer.tsx"; +import { cacheAttemptVariant, cacheFreshnessVariant } from "./cachePresentation.ts"; + +const minimumVirtualizedRows = 50; +const cacheStatusTableFeatures = tableFeatures({}); + +interface CacheStatusTableRow { + readonly entry: CacheEntryStatus; + readonly onSelect: (key: string) => void; + readonly selected: boolean; +} + +const cacheStatusColumnHelper = createColumnHelper< + typeof cacheStatusTableFeatures, + CacheStatusTableRow +>(); + +const cacheStatusColumns = cacheStatusColumnHelper.columns([ + cacheStatusColumnHelper.accessor((row) => row.entry.key, { + cell: ({ getValue, row }) => ( + + ), + header: "Entry", + id: "key", + }), + cacheStatusColumnHelper.accessor((row) => row.entry.freshness, { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Freshness", + id: "freshness", + }), + cacheStatusColumnHelper.accessor((row) => row.entry.lastAttemptStatus, { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Last attempt", + id: "lastAttemptStatus", + }), + cacheStatusColumnHelper.accessor((row) => row.entry.updatedAtMs, { + cell: ({ getValue }) => ( + + ), + header: "Updated", + id: "updatedAtMs", + }), + cacheStatusColumnHelper.accessor((row) => row.entry.manualRunAvailable, { + cell: ({ getValue }) => ( + + {getValue() ? "Available" : "Unavailable"} + + ), + header: "Manual refresh", + id: "manualRunAvailable", + }), +]); + +interface CacheStatusTableProps { + readonly entries: readonly CacheEntryStatus[]; + readonly onSelect: (key: string) => void; + readonly selectedKey?: string; +} + +/** @returns Selectable, bounded cache status inventory with large-page virtualization. */ +export function CacheStatusTable({ + entries, + onSelect, + selectedKey, +}: CacheStatusTableProps) { + const table = useTable({ + columns: cacheStatusColumns, + data: entries.map((entry) => ({ + entry, + onSelect, + selected: entry.key === selectedKey, + })), + features: cacheStatusTableFeatures, + getRowId: ({ entry }) => entry.key, + }); + const rows = table.getRowModel().rows; + const tableElement = (rowWindow?: VirtualizerRenderState) => ( + + ); + + if (rows.length < minimumVirtualizedRows) return tableElement(); + return ( + + count={rows.length} + estimateSize={() => 58} + getItemKey={(index) => rows[index]?.id ?? `missing-cache-entry-${index}`} + > + {(virtualization) => tableElement(virtualization)} + + ); +} diff --git a/greenfield/src/browser/cache/SystemHostCard.test.tsx b/greenfield/src/browser/cache/SystemHostCard.test.tsx new file mode 100644 index 000000000..ad2cd0c53 --- /dev/null +++ b/greenfield/src/browser/cache/SystemHostCard.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; + +import type { CacheEntry } from "../../contracts/cache.ts"; +import { SystemHostCard } from "./SystemHostCard.tsx"; + +const { render, screen } = await import("@testing-library/react"); + +const timestampMs = 1_800_000_000_000; + +function entry(payload: CacheEntry["payload"]): CacheEntry { + return { + consecutiveFailures: 1, + expiresAtMs: timestampMs + 60_000, + failureCode: "provider.failed", + failureMessage: "The latest refresh failed.", + freshness: "fresh", + key: "system.host", + lastAttemptAtMs: timestampMs, + lastAttemptDurationMs: 200, + lastAttemptNumber: 2, + lastAttemptRunId: "019fe000-0000-7000-8000-000000000002", + lastAttemptStatus: "failed", + lastSuccessAtMs: timestampMs - 1000, + manualRunAvailable: true, + metadata: {}, + payload, + schemaId: "system.host.v1", + source: "system.host", + updatedAtMs: timestampMs, + }; +} + +describe("SystemHostCard", () => { + test("renders only reviewed system.host fields and independent statuses", () => { + render( + + ); + expect(screen.getByRole("heading", { name: "mira-vps" })).toBeTruthy(); + expect(screen.getByText("fresh")).toBeTruthy(); + expect(screen.getByText("last attempt failed")).toBeTruthy(); + expect(screen.getByRole("progressbar", { name: "Memory used" })).toBeTruthy(); + expect(screen.getByText("75% used · 2.0 GiB free")).toBeTruthy(); + expect(screen.getByText("Uptime 2d 3h")).toBeTruthy(); + }); + + test("fails closed when a generic cache payload is not a system.host payload", () => { + render(); + expect( + screen.getByRole("heading", { name: "Host projection unavailable" }) + ).toBeTruthy(); + expect(screen.queryByText("value")).toBeNull(); + }); + + test("fails closed when the payload shape is not bound to the reviewed schema", () => { + const validPayload = { + architecture: "x64", + disk: { freeBytes: 40, path: "/" as const, totalBytes: 100 }, + hostname: "must-not-render", + memory: { freeBytes: 20, totalBytes: 100 }, + platform: "linux", + release: "6.8.0", + uptimeSeconds: 60, + }; + render( + + ); + expect( + screen.getByRole("heading", { name: "Host projection unavailable" }) + ).toBeTruthy(); + expect(screen.queryByText("must-not-render")).toBeNull(); + }); +}); diff --git a/greenfield/src/browser/cache/SystemHostCard.tsx b/greenfield/src/browser/cache/SystemHostCard.tsx new file mode 100644 index 000000000..347bfca45 --- /dev/null +++ b/greenfield/src/browser/cache/SystemHostCard.tsx @@ -0,0 +1,107 @@ +import * as v from "valibot"; + +import { type CacheEntry, systemHostCachePayloadSchema } from "../../contracts/cache.ts"; +import { Badge } from "../ui/Badge.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Text } from "../ui/Text.tsx"; +import { + cacheAttemptVariant, + cacheFreshnessVariant, + formatCacheBytes, + formatCacheUptime, +} from "./cachePresentation.ts"; + +interface CapacityMeterProps { + readonly freeBytes: number; + readonly label: string; + readonly totalBytes: number; +} + +function CapacityMeter({ freeBytes, label, totalBytes }: CapacityMeterProps) { + const usedBytes = totalBytes - freeBytes; + const usedPercent = totalBytes === 0 ? 0 : Math.round((usedBytes / totalBytes) * 100); + return ( +
+
+ {label} + + {formatCacheBytes(usedBytes)} / {formatCacheBytes(totalBytes)} + +
+ + + {usedPercent}% used · {formatCacheBytes(freeBytes)} free + +
+ ); +} + +interface SystemHostCardProps { + readonly entry: CacheEntry; +} + +/** @returns Reviewed system.host fields, or a fixed schema warning for invalid payloads. */ +export function SystemHostCard({ entry }: SystemHostCardProps) { + const parsed = v.safeParse(systemHostCachePayloadSchema, entry.payload); + if ( + entry.key !== "system.host" || + entry.schemaId !== "system.host.v1" || + entry.source !== "system.host" || + !parsed.success + ) { + return ( + + + Host projection unavailable + + + The cached host payload does not match the reviewed system.host + schema. + + + ); + } + const host = parsed.output; + return ( + +
+
+ + Host projection + + + {host.hostname} + + + {host.platform} {host.release} · {host.architecture} + +
+
+ + {entry.freshness} + + + last attempt {entry.lastAttemptStatus} + +
+
+
+ + +
+ + Uptime {formatCacheUptime(host.uptimeSeconds)} + +
+ ); +} diff --git a/greenfield/src/browser/cache/cacheMutations.test.tsx b/greenfield/src/browser/cache/cacheMutations.test.tsx new file mode 100644 index 000000000..baad2d791 --- /dev/null +++ b/greenfield/src/browser/cache/cacheMutations.test.tsx @@ -0,0 +1,485 @@ +import { describe, expect, test } from "bun:test"; + +import { QueryClientProvider } from "@tanstack/react-query"; +import type { TRPCRequestOptions } from "@trpc/client"; +import { act, type ReactNode } from "react"; +import * as v from "valibot"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import { refreshCacheEntryInputSchema } from "../../contracts/cache.ts"; +import type { JobRunSummary, ScheduleSummary } from "../../contracts/jobModel.ts"; +import type { ListJobRunsResult } from "../../contracts/jobs.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; +import { + authStatusQueryKey, + resetAuthenticatedBrowserCache, +} from "../auth/authQueries.ts"; +import type { DashboardBrowserCollections } from "../data/dashboardCollections.ts"; +import { + jobQueueSummaryQueryKey, + jobRunDetailQueryKey, + jobRunListQueryKey, + scheduleDetailQueryKey, + scheduleListQueryKey, +} from "../jobs/jobQueries.ts"; +import { + createCacheRefreshIdempotencyKey, + useRefreshCacheEntryMutation, +} from "./cacheMutations.ts"; +import { cacheBrowserFailureMessage } from "./cachePresentation.ts"; +import { cacheEntryQueryKey, cacheStatusQueryKey } from "./cacheQueries.ts"; + +const { renderHook, waitFor } = await import("@testing-library/react"); + +const cacheKey = "system.host"; +const otherCacheKey = "system.metrics"; +const scheduleId = "cache.system-host"; +const runId = "019fdf70-0000-7000-8000-000000000002"; +const nextRunId = "019fdf80-0000-7000-8000-000000000003"; +const newestRunId = "019fdf90-0000-7000-8000-000000000004"; +const timestampMs = 1_800_000_000_000; + +function authenticatedStatus( + sessionMarker = "a", + userId = "019fd974-54a2-74dd-a64b-d4186f8d8828" +): Extract { + return { + session: { + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: sessionMarker.repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + }, + state: "authenticated", + user: { id: userId, username: `operator-${sessionMarker}` }, + }; +} + +function queuedRun(id = runId, updatedAtMs = timestampMs): JobRunSummary { + return { + actionKey: "cache.refresh.system-host", + attemptCount: 0, + attemptLimit: 3, + availableAtMs: timestampMs, + cancellationPolicy: "cooperative", + displayName: "System host cache", + eventCount: 1, + id, + priority: 0, + queuedAtMs: timestampMs, + resourceClass: "light", + resourceKeys: ["cache.system.host"], + retrySafe: true, + scheduledJobId: scheduleId, + scheduledJobVersion: 1, + state: "queued", + stateVersion: 1, + timeoutMs: 30_000, + triggerType: "manual", + updatedAtMs, + }; +} + +function cacheSchedule(): ScheduleSummary { + return { + actionKey: "cache.refresh.system-host", + attemptLimit: 3, + cancellationPolicy: "cooperative", + createdAtMs: timestampMs - 10_000, + description: "Projects bounded host, memory, and root-filesystem status.", + enabled: true, + id: scheduleId, + manualRunAvailable: false, + name: "System host cache", + nextRunAtMs: timestampMs + 86_400_000, + priority: 0, + resourceClass: "light", + resourceKeys: ["cache.system.host"], + retrySafe: true, + schedule: { intervalMs: 86_400_000, kind: "interval" }, + timeoutMs: 30_000, + updatedAtMs: timestampMs, + version: 1, + }; +} + +function queueSummary(): ListJobRunsResult["summary"] { + return { + activeResourceClasses: [], + control: { claimingPaused: false, updatedAtMs: timestampMs, version: 1 }, + oldestQueuedAtMs: timestampMs, + stateCounts: { + cancelled: 0, + failed: 0, + queued: 1, + running: 0, + succeeded: 0, + "timed-out": 0, + }, + workers: [], + }; +} + +interface MutationCall { + readonly input: unknown; + readonly path: string; + readonly signal: AbortSignal | undefined; +} + +class CacheMutationTransport implements DashboardTrpcTransport { + readonly calls: MutationCall[] = []; + readonly #outputs: unknown[]; + + constructor(outputs: unknown[]) { + this.#outputs = outputs; + } + + mutation( + path: string, + input?: unknown, + options?: TRPCRequestOptions + ): Promise { + const output = this.#outputs[this.calls.length]; + this.calls.push({ input, path, signal: options?.signal }); + return output instanceof Error ? Promise.reject(output) : Promise.resolve(output); + } + + query(path: string): Promise { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } +} + +function renderRefreshMutation( + queryClient: ReturnType, + transport: CacheMutationTransport, + createIdempotencyKey: () => string +) { + const client = createDashboardTrpcClient(transport); + return renderHook(() => useRefreshCacheEntryMutation(createIdempotencyKey), { + wrapper: ({ children }: { readonly children: ReactNode }) => ( + + {children} + + ), + }); +} + +describe("cache browser refresh mutation", () => { + test("creates a contract-shaped random idempotency key", () => { + const idempotencyKey = createCacheRefreshIdempotencyKey(); + + expect(idempotencyKey).toMatch(/^[A-Fa-f0-9]{32}$/u); + expect( + v.parse(refreshCacheEntryInputSchema, { + idempotencyKey, + key: cacheKey, + }).idempotencyKey + ).toBe(idempotencyKey); + }); + + test("reuses independent lost-response keys and rotates only confirmed entries", async () => { + const firstError = new TypeError("ambiguous host response"); + const secondError = new TypeError("ambiguous metrics response"); + const transport = new CacheMutationTransport([ + firstError, + secondError, + queuedRun(), + queuedRun(nextRunId), + queuedRun(newestRunId), + ]); + const generatedKeys = ["A".repeat(32), "B".repeat(32), "C".repeat(32)]; + let generationCount = 0; + const queryClient = createDashboardQueryClient(); + queryClient.setQueryData(authStatusQueryKey, authenticatedStatus()); + const rendered = renderRefreshMutation(queryClient, transport, () => { + const key = generatedKeys[generationCount]; + generationCount += 1; + if (key === undefined) throw new TypeError("Missing test key"); + return key; + }); + + try { + let failure: unknown; + await act(async () => { + failure = await rendered.result.current + .mutateAsync({ key: cacheKey }) + .catch((error: unknown) => error); + }); + expect(failure).toBe(firstError); + expect(rendered.result.current.hasPendingRequest(cacheKey)).toBeTrue(); + + await act(async () => { + failure = await rendered.result.current + .mutateAsync({ key: otherCacheKey }) + .catch((error: unknown) => error); + }); + expect(failure).toBe(secondError); + expect(rendered.result.current.hasPendingRequest(otherCacheKey)).toBeTrue(); + + let confirmedRun: JobRunSummary | undefined; + await act(async () => { + confirmedRun = await rendered.result.current.mutateAsync({ + key: cacheKey, + }); + }); + expect(confirmedRun?.state).toBe("queued"); + expect(rendered.result.current.hasPendingRequest(cacheKey)).toBeFalse(); + expect(rendered.result.current.hasPendingRequest(otherCacheKey)).toBeTrue(); + + await act(async () => { + await rendered.result.current.mutateAsync({ key: otherCacheKey }); + }); + expect(rendered.result.current.hasPendingRequest(otherCacheKey)).toBeFalse(); + + await act(async () => { + await rendered.result.current.mutateAsync({ key: cacheKey }); + }); + + expect(transport.calls.map(({ input }) => input)).toEqual([ + { idempotencyKey: generatedKeys[0], key: cacheKey }, + { idempotencyKey: generatedKeys[1], key: otherCacheKey }, + { idempotencyKey: generatedKeys[0], key: cacheKey }, + { idempotencyKey: generatedKeys[1], key: otherCacheKey }, + { idempotencyKey: generatedKeys[2], key: cacheKey }, + ]); + expect(transport.calls.map(({ path }) => path)).toEqual( + Array.from({ length: 5 }, () => "cache.refreshEntry") + ); + expect( + transport.calls.every(({ signal }) => signal instanceof AbortSignal) + ).toBeTrue(); + expect(generationCount).toBe(3); + } finally { + rendered.unmount(); + queryClient.clear(); + } + }); + + test("preserves ambiguous errors and invalidates retained projections safely", async () => { + const rawError = new TypeError("private transport detail"); + const transport = new CacheMutationTransport([rawError]); + const queryClient = createDashboardQueryClient(); + const statusData = { + entries: [], + generatedAtMs: timestampMs, + totalCount: 0, + truncated: false, + }; + const entryData = { sentinel: "last-known-good" }; + const jobData = { sentinel: "jobs" }; + const scheduleData = { sentinel: "schedules" }; + const exactCacheKey = cacheEntryQueryKey(cacheKey); + const jobKey = ["jobs", "retained"] as const; + const scheduleKey = ["schedules", "retained"] as const; + queryClient.setQueryData(authStatusQueryKey, authenticatedStatus()); + queryClient.setQueryData(cacheStatusQueryKey, statusData); + queryClient.setQueryData(exactCacheKey, entryData); + queryClient.setQueryData(jobKey, jobData); + queryClient.setQueryData(scheduleKey, scheduleData); + const rendered = renderRefreshMutation(queryClient, transport, () => + "D".repeat(32) + ); + + try { + let failure: unknown; + await act(async () => { + failure = await rendered.result.current + .mutateAsync({ key: cacheKey }) + .catch((error: unknown) => error); + }); + + expect(failure).toBe(rawError); + await waitFor(() => expect(rendered.result.current.error).toBe(rawError)); + expect(rendered.result.current.failureMessage).toBe( + cacheBrowserFailureMessage(rawError) + ); + expect(rendered.result.current.failureMessage).not.toContain( + rawError.message + ); + expect(rendered.result.current.hasPendingRequest(cacheKey)).toBeTrue(); + await waitFor(() => { + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeTrue(); + expect( + queryClient.getQueryState(exactCacheKey)?.isInvalidated + ).toBeTrue(); + expect(queryClient.getQueryState(jobKey)?.isInvalidated).toBeTrue(); + expect(queryClient.getQueryState(scheduleKey)?.isInvalidated).toBeTrue(); + }); + expect(queryClient.getQueryData(cacheStatusQueryKey)).toBe( + statusData + ); + expect(queryClient.getQueryData(exactCacheKey)).toBe( + entryData + ); + expect(queryClient.getQueryData(jobKey)).toBe(jobData); + expect(queryClient.getQueryData(scheduleKey)).toBe( + scheduleData + ); + } finally { + rendered.unmount(); + queryClient.clear(); + } + }); + + test("drops pending keys when the authenticated cache generation rotates", async () => { + const rawError = new TypeError("ambiguous previous-user response"); + const transport = new CacheMutationTransport([rawError, queuedRun()]); + const queryClient = createDashboardQueryClient(); + const generatedKeys = ["E".repeat(32), "F".repeat(32)]; + let generationCount = 0; + const createIdempotencyKey = () => { + const key = generatedKeys[generationCount]; + generationCount += 1; + if (key === undefined) throw new TypeError("Missing test key"); + return key; + }; + const collections = { + cleanup: () => Promise.resolve(), + get agents(): DashboardBrowserCollections["agents"] { + throw new TypeError("Agents collection should not be read"); + }, + get notifications(): DashboardBrowserCollections["notifications"] { + throw new TypeError("Notification collection should not be read"); + }, + reset: () => Promise.resolve(), + } satisfies DashboardBrowserCollections; + queryClient.setQueryData(authStatusQueryKey, authenticatedStatus()); + let rendered = renderRefreshMutation( + queryClient, + transport, + createIdempotencyKey + ); + + try { + await act(async () => { + await rendered.result.current + .mutateAsync({ key: cacheKey }) + .catch(() => {}); + }); + expect(rendered.result.current.hasPendingRequest(cacheKey)).toBeTrue(); + rendered.unmount(); + + await resetAuthenticatedBrowserCache( + queryClient, + collections, + authenticatedStatus("b", "019fd974-54a2-74dd-a64b-d4186f8d8829") + ); + rendered = renderRefreshMutation( + queryClient, + transport, + createIdempotencyKey + ); + expect(rendered.result.current.hasPendingRequest(cacheKey)).toBeFalse(); + + await act(async () => { + await rendered.result.current.mutateAsync({ key: cacheKey }); + }); + expect(transport.calls.map(({ input }) => input)).toEqual([ + { idempotencyKey: generatedKeys[0], key: cacheKey }, + { idempotencyKey: generatedKeys[1], key: cacheKey }, + ]); + expect(generationCount).toBe(2); + } finally { + rendered.unmount(); + queryClient.clear(); + } + }); + + test("patches a confirmed queued run and invalidates cache, jobs, and schedules", async () => { + const previousRun = queuedRun(runId, timestampMs); + const confirmedRun = queuedRun(runId, timestampMs + 1000); + const transport = new CacheMutationTransport([confirmedRun]); + const queryClient = createDashboardQueryClient(); + const jobListKey = jobRunListQueryKey(undefined); + const scheduleListKey = scheduleListQueryKey("enabled"); + const schedule = cacheSchedule(); + const exactCacheKey = cacheEntryQueryKey(cacheKey); + queryClient.setQueryData(authStatusQueryKey, authenticatedStatus()); + queryClient.setQueryData(cacheStatusQueryKey, { + entries: [], + generatedAtMs: timestampMs, + totalCount: 0, + truncated: false, + }); + queryClient.setQueryData(exactCacheKey, { sentinel: "last-known-good" }); + queryClient.setQueryData(jobListKey, { + pageParams: [undefined], + pages: [{ runs: [previousRun], summary: queueSummary() }], + }); + queryClient.setQueryData(jobQueueSummaryQueryKey, { + runs: [previousRun], + summary: queueSummary(), + }); + queryClient.setQueryData(jobRunDetailQueryKey(runId), { + events: [], + run: previousRun, + }); + queryClient.setQueryData(scheduleListKey, { + pageParams: [undefined], + pages: [{ schedules: [schedule] }], + }); + queryClient.setQueryData(scheduleDetailQueryKey(scheduleId), schedule); + const rendered = renderRefreshMutation(queryClient, transport, () => + "G".repeat(32) + ); + + try { + let result: JobRunSummary | undefined; + await act(async () => { + result = await rendered.result.current.mutateAsync({ key: cacheKey }); + }); + + expect(result).toEqual(confirmedRun); + expect(result?.state).toBe("queued"); + expect(rendered.result.current.failureMessage).toBeUndefined(); + expect(rendered.result.current.hasPendingRequest(cacheKey)).toBeFalse(); + expect( + queryClient.getQueryData<{ + readonly pages: readonly { readonly runs: JobRunSummary[] }[]; + }>(jobListKey)?.pages[0]?.runs + ).toEqual([confirmedRun]); + expect( + queryClient.getQueryData<{ readonly run: JobRunSummary }>( + jobRunDetailQueryKey(runId) + )?.run + ).toEqual(confirmedRun); + expect( + queryClient.getQueryData( + scheduleDetailQueryKey(scheduleId) + ) + ).toMatchObject({ activeRun: confirmedRun, latestRun: confirmedRun }); + await waitFor(() => { + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeTrue(); + expect( + queryClient.getQueryState(exactCacheKey)?.isInvalidated + ).toBeTrue(); + expect(queryClient.getQueryState(jobListKey)?.isInvalidated).toBeTrue(); + expect( + queryClient.getQueryState(jobRunDetailQueryKey(runId))?.isInvalidated + ).toBeTrue(); + expect( + queryClient.getQueryState(scheduleListKey)?.isInvalidated + ).toBeTrue(); + expect( + queryClient.getQueryState(scheduleDetailQueryKey(scheduleId)) + ?.isInvalidated + ).toBeTrue(); + }); + } finally { + rendered.unmount(); + queryClient.clear(); + } + }); +}); diff --git a/greenfield/src/browser/cache/cacheMutations.ts b/greenfield/src/browser/cache/cacheMutations.ts new file mode 100644 index 000000000..0d9a17d72 --- /dev/null +++ b/greenfield/src/browser/cache/cacheMutations.ts @@ -0,0 +1,107 @@ +import { type QueryClient, useMutation } from "@tanstack/react-query"; + +import type { + DashboardProcedureInput, + DashboardProcedureOutput, +} from "../api/trpcClient.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { authenticatedBrowserCacheGeneration } from "../auth/authQueries.ts"; +import { useAuthenticatedMutationBoundary } from "../auth/useAuthenticatedMutationBoundary.ts"; +import { patchJobRunInCachedQueries } from "../jobs/jobMutations.ts"; +import { refreshJobAndScheduleQueries } from "../jobs/jobQueries.ts"; +import { cacheBrowserFailureMessage } from "./cachePresentation.ts"; +import { refreshCacheQueriesForEntry } from "./cacheQueries.ts"; + +export const cacheRefreshMutationKey = ["cache", "mutation", "refresh-entry"] as const; + +interface PendingCacheRefreshKeys { + readonly cacheGeneration: number; + readonly keys: Map; +} + +const pendingCacheRefreshKeys = new WeakMap(); + +function cacheRefreshKeysForCurrentCache(queryClient: QueryClient): Map { + const cacheGeneration = authenticatedBrowserCacheGeneration(queryClient); + const current = pendingCacheRefreshKeys.get(queryClient); + if (current?.cacheGeneration === cacheGeneration) return current.keys; + const keys = new Map(); + pendingCacheRefreshKeys.set(queryClient, { cacheGeneration, keys }); + return keys; +} + +function refreshBestEffort(refresh: () => Promise): void { + void refresh().catch(() => { + // Validated mutation data remains usable until a later refresh succeeds. + }); +} + +async function refreshCacheRunProjections( + queryClient: QueryClient, + key: string +): Promise { + await Promise.allSettled([ + refreshCacheQueriesForEntry(queryClient, key), + refreshJobAndScheduleQueries(queryClient), + ]); +} + +/** Variables intentionally omit the authenticated cache-owned idempotency key. */ +export interface RefreshCacheEntryMutationInput { + readonly key: DashboardProcedureInput<"cache.refreshEntry">["key"]; +} + +/** @returns One caller-scoped, contract-valid lost-response idempotency key. */ +export function createCacheRefreshIdempotencyKey(): string { + return globalThis.crypto.randomUUID().replaceAll("-", ""); +} + +/** + * Enqueues one authenticated cache refresh without claiming provider completion. + * @param createIdempotencyKey Secure generator used once per confirmed-success cycle. + * @returns Lost-response-safe mutation state and a fixed browser-safe failure message. + */ +export function useRefreshCacheEntryMutation( + createIdempotencyKey: () => string = createCacheRefreshIdempotencyKey +) { + const client = useDashboardTrpcClient(); + const boundary = useAuthenticatedMutationBoundary(); + const mutation = useMutation< + DashboardProcedureOutput<"cache.refreshEntry">, + Error, + RefreshCacheEntryMutationInput + >({ + mutationKey: cacheRefreshMutationKey, + mutationFn: ({ key }) => + boundary.run((signal) => { + const pendingKeys = cacheRefreshKeysForCurrentCache(boundary.queryClient); + const idempotencyKey = pendingKeys.get(key) ?? createIdempotencyKey(); + pendingKeys.set(key, idempotencyKey); + return client.mutation( + "cache.refreshEntry", + { idempotencyKey, key }, + { signal } + ); + }), + onSettled: (_run, _error, input) => { + if (!boundary.completionIsCurrent()) return; + refreshBestEffort(() => + refreshCacheRunProjections(boundary.queryClient, input.key) + ); + }, + onSuccess: (run, input) => { + if (!boundary.completionIsCurrent()) return; + cacheRefreshKeysForCurrentCache(boundary.queryClient).delete(input.key); + patchJobRunInCachedQueries(boundary.queryClient, run, true); + }, + }); + return { + ...mutation, + failureMessage: + mutation.error === null + ? undefined + : cacheBrowserFailureMessage(mutation.error), + hasPendingRequest: (key: string): boolean => + cacheRefreshKeysForCurrentCache(boundary.queryClient).has(key), + }; +} diff --git a/greenfield/src/browser/cache/cachePresentation.test.ts b/greenfield/src/browser/cache/cachePresentation.test.ts new file mode 100644 index 000000000..e13f538b6 --- /dev/null +++ b/greenfield/src/browser/cache/cachePresentation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { DashboardProtocolError } from "../api/trpcClient.ts"; +import { + cacheAttemptVariant, + cacheBrowserFailureMessage, + cacheFreshnessVariant, + formatCacheBytes, + formatCacheDuration, + formatCacheUptime, +} from "./cachePresentation.ts"; + +describe("cache presentation", () => { + test("keeps freshness and attempt outcomes visually independent", () => { + expect(cacheFreshnessVariant("fresh")).toBe("success"); + expect(cacheFreshnessVariant("stale")).toBe("warning"); + expect(cacheFreshnessVariant("missing")).toBe("default"); + expect(cacheAttemptVariant("succeeded")).toBe("success"); + expect(cacheAttemptVariant("failed")).toBe("danger"); + }); + + test("formats bounded capacity, attempt duration, and uptime", () => { + expect(formatCacheBytes(0)).toBe("0 B"); + expect(formatCacheBytes(1536)).toBe("1.5 KiB"); + expect(formatCacheBytes(10 * 1024 ** 3)).toBe("10 GiB"); + expect(formatCacheDuration(950)).toBe("950 ms"); + expect(formatCacheDuration(1500)).toBe("1.5 s"); + expect(formatCacheDuration(12_400)).toBe("12 s"); + expect(formatCacheUptime(59)).toBe("0m"); + expect(formatCacheUptime(7380)).toBe("2h 3m"); + expect(formatCacheUptime(183_600)).toBe("2d 3h"); + }); + + test("never renders an untrusted transport message", () => { + const secret = "do-not-render-this-secret"; + expect(cacheBrowserFailureMessage(new TypeError(secret))).toBe( + "The cache request could not be completed. Try again." + ); + expect(cacheBrowserFailureMessage(new DashboardProtocolError())).toBe( + "The server returned an invalid cache response. Reload before trying again." + ); + expect(cacheBrowserFailureMessage({ data: { code: "NOT_FOUND" } })).toBe( + "The selected cache entry is no longer available." + ); + }); +}); diff --git a/greenfield/src/browser/cache/cachePresentation.ts b/greenfield/src/browser/cache/cachePresentation.ts new file mode 100644 index 000000000..eff38aed2 --- /dev/null +++ b/greenfield/src/browser/cache/cachePresentation.ts @@ -0,0 +1,97 @@ +import type { CacheEntryStatus } from "../../contracts/cache.ts"; +import { + classifyDashboardBrowserFailure, + type DashboardBrowserFailure, +} from "../api/trpcError.ts"; + +const byteUnits = ["B", "KiB", "MiB", "GiB", "TiB"] as const; + +/** + * @param freshness Independently derived cache freshness. + * @returns The shared badge tone for one independently derived freshness state. + */ +export function cacheFreshnessVariant( + freshness: CacheEntryStatus["freshness"] +): "default" | "success" | "warning" { + if (freshness === "fresh") return "success"; + if (freshness === "stale") return "warning"; + return "default"; +} + +/** + * @param status Latest refresh attempt outcome. + * @returns The shared badge tone for the latest refresh attempt outcome. + */ +export function cacheAttemptVariant( + status: CacheEntryStatus["lastAttemptStatus"] +): "danger" | "success" { + return status === "succeeded" ? "success" : "danger"; +} + +/** + * Formats a nonnegative byte count without exposing locale- or host-specific state. + * @param bytes Validated cache byte count. + * @returns Compact binary-capacity label. + */ +export function formatCacheBytes(bytes: number): string { + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < byteUnits.length - 1) { + value /= 1024; + unitIndex += 1; + } + const digits = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(digits)} ${byteUnits[unitIndex]}`; +} + +/** + * Formats a nonnegative duration for concise operator metadata. + * @param milliseconds Validated duration in milliseconds. + * @returns Compact duration label. + */ +export function formatCacheDuration(milliseconds: number): string { + if (milliseconds < 1000) return `${milliseconds} ms`; + const seconds = milliseconds / 1000; + return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)} s`; +} + +/** + * Formats validated host uptime without relying on wall-clock state. + * @param seconds Whole uptime seconds. + * @returns Human-readable bounded uptime. + */ +export function formatCacheUptime(seconds: number): string { + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +const cacheFailureMessages: Readonly> = { + cancelled: "The cache request was cancelled. You can try again.", + conflict: + "The cache refresh state changed. Review the current run and try again if needed.", + forbidden: "This session is not permitted to access that cache operation.", + "invalid-request": "The cache request was rejected. Refresh the page and try again.", + "mfa-enrollment-required": + "Multi-factor authentication must be enrolled before this action.", + "not-found": "The selected cache entry is no longer available.", + protocol: + "The server returned an invalid cache response. Reload before trying again.", + "rate-limited": "Too many cache requests were made. Wait before trying again.", + "step-up-required": "Verify your identity again before refreshing this cache entry.", + unauthorized: "The credentials or session are no longer valid.", + unavailable: "Cache data is temporarily unavailable. Try again shortly.", + unknown: "The cache request could not be completed. Try again.", +}; + +/** + * Maps untrusted transport failures to fixed cache-specific operator text. + * @param error Unknown transport rejection. + * @returns Safe browser-facing cache message. + */ +export function cacheBrowserFailureMessage(error: unknown): string { + return cacheFailureMessages[classifyDashboardBrowserFailure(error)]; +} diff --git a/greenfield/src/browser/cache/cacheQueries.test.ts b/greenfield/src/browser/cache/cacheQueries.test.ts new file mode 100644 index 000000000..b7f945e4f --- /dev/null +++ b/greenfield/src/browser/cache/cacheQueries.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, jest, test } from "bun:test"; + +import { QueryObserver } from "@tanstack/react-query"; +import type { TRPCRequestOptions } from "@trpc/client"; + +import type { + CacheEntry, + CacheEntryStatus, + CacheStatusResult, +} from "../../contracts/cache.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { + cacheEntryQueryKey, + cacheEntryQueryOptions, + cacheEntryQueryRoot, + cacheStatusQueryKey, + cacheStatusQueryOptions, + cacheStatusRefreshIntervalMs, +} from "./cacheQueries.ts"; + +const systemHostKey = "system.host"; +const timestampMs = 1_800_000_000_000; +const runId = "019fc968-1a9b-7765-8f1b-d5b863b0e7b4"; + +const systemHostEntry = Object.freeze({ + consecutiveFailures: 0, + expiresAtMs: timestampMs + 60_000, + freshness: "fresh", + key: systemHostKey, + lastAttemptAtMs: timestampMs, + lastAttemptDurationMs: 25, + lastAttemptNumber: 1, + lastAttemptRunId: runId, + lastAttemptStatus: "succeeded", + lastSuccessAtMs: timestampMs, + manualRunAvailable: true, + metadata: { provider: "node" }, + payload: { + architecture: "x64", + disk: { freeBytes: 500, path: "/", totalBytes: 1000 }, + hostname: "dashboard", + memory: { freeBytes: 1000, totalBytes: 2000 }, + platform: "linux", + release: "6.8.0", + uptimeSeconds: 60, + }, + schemaId: "system.host.v1", + source: systemHostKey, + updatedAtMs: timestampMs, +} as const satisfies CacheEntry); + +const systemHostStatus = Object.freeze( + (({ payload: _payload, ...status }) => status)(systemHostEntry) +) satisfies CacheEntryStatus; + +function cacheStatus( + generatedAtMs = timestampMs, + entry: CacheEntryStatus = systemHostStatus +): CacheStatusResult { + return { + entries: [entry], + generatedAtMs, + totalCount: 1, + truncated: false, + }; +} + +interface QueryCall { + readonly input: unknown; + readonly path: string; + readonly signal: AbortSignal | undefined; +} + +class CacheQueryTransport implements DashboardTrpcTransport { + readonly calls: QueryCall[] = []; + readonly #outputs: Readonly>; + + constructor(outputs: Readonly>) { + this.#outputs = outputs; + } + + mutation(path: string): Promise { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + + query(path: string, input?: unknown, options?: TRPCRequestOptions): Promise { + const callIndex = this.calls.filter((call) => call.path === path).length; + this.calls.push({ input, path, signal: options?.signal }); + const output = this.#outputs[path]?.[callIndex]; + if (output === undefined) { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + return output instanceof Error ? Promise.reject(output) : Promise.resolve(output); + } +} + +describe("cache browser queries", () => { + test("polls the mounted bounded status snapshot as freshness advances", async () => { + jest.useFakeTimers(); + const staleStatus = { + ...systemHostStatus, + freshness: "stale" as const, + }; + const transport = new CacheQueryTransport({ + "cache.getStatus": [ + cacheStatus(), + cacheStatus(systemHostEntry.expiresAtMs, staleStatus), + ], + }); + const queryClient = createDashboardQueryClient(); + const observer = new QueryObserver( + queryClient, + cacheStatusQueryOptions(createDashboardTrpcClient(transport)) + ); + const freshResult = Promise.withResolvers(); + const staleResult = Promise.withResolvers(); + const unsubscribe = observer.subscribe((result) => { + if (result.data?.entries[0]?.freshness === "fresh") { + freshResult.resolve(); + } + if ( + result.data?.entries[0]?.freshness === "stale" && + transport.calls.length === 2 + ) { + staleResult.resolve(); + } + }); + + try { + await freshResult.promise; + expect(transport.calls).toHaveLength(1); + jest.advanceTimersByTime(cacheStatusRefreshIntervalMs); + await staleResult.promise; + + expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([ + { input: {}, path: "cache.getStatus" }, + { input: {}, path: "cache.getStatus" }, + ]); + } finally { + unsubscribe(); + queryClient.clear(); + jest.useRealTimers(); + } + }); + + test("loads only the selected exact entry with stable keys and cancellation signals", async () => { + const transport = new CacheQueryTransport({ + "cache.getEntry": [systemHostEntry], + "cache.getStatus": [cacheStatus()], + }); + const queryClient = createDashboardQueryClient(); + const client = createDashboardTrpcClient(transport); + + try { + const status = await queryClient.fetchQuery(cacheStatusQueryOptions(client)); + const selected = await queryClient.fetchQuery( + cacheEntryQueryOptions(client, systemHostKey) + ); + + expect(status.entries).toHaveLength(1); + expect(selected).toEqual(systemHostEntry); + expect(cacheStatusQueryOptions(client).queryKey).toEqual(cacheStatusQueryKey); + expect(cacheStatusQueryOptions(client).refetchOnMount).toBe("always"); + expect(cacheEntryQueryKey(systemHostKey)).toEqual([ + ...cacheEntryQueryRoot, + systemHostKey, + ]); + expect(cacheEntryQueryOptions(client, systemHostKey).refetchOnMount).toBe( + "always" + ); + expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([ + { input: {}, path: "cache.getStatus" }, + { + input: { key: systemHostKey }, + path: "cache.getEntry", + }, + ]); + expect( + transport.calls.every(({ signal }) => signal instanceof AbortSignal) + ).toBeTrue(); + } finally { + queryClient.clear(); + } + }); + + test("polls the selected exact entry as its derived freshness advances", async () => { + jest.useFakeTimers(); + const staleEntry = { + ...systemHostEntry, + freshness: "stale" as const, + } satisfies CacheEntry; + const transport = new CacheQueryTransport({ + "cache.getEntry": [systemHostEntry, staleEntry], + }); + const queryClient = createDashboardQueryClient(); + const observer = new QueryObserver( + queryClient, + cacheEntryQueryOptions(createDashboardTrpcClient(transport), systemHostKey) + ); + const freshResult = Promise.withResolvers(); + const staleResult = Promise.withResolvers(); + const unsubscribe = observer.subscribe((result) => { + if (result.data?.freshness === "fresh") freshResult.resolve(); + if (result.data?.freshness === "stale" && transport.calls.length === 2) { + staleResult.resolve(); + } + }); + + try { + await freshResult.promise; + jest.advanceTimersByTime(cacheStatusRefreshIntervalMs); + await staleResult.promise; + expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([ + { input: { key: systemHostKey }, path: "cache.getEntry" }, + { input: { key: systemHostKey }, path: "cache.getEntry" }, + ]); + } finally { + unsubscribe(); + queryClient.clear(); + jest.useRealTimers(); + } + }); + + test("reconciles a selected exact entry when its observer remounts while fresh", async () => { + const remountedEntry = { + ...systemHostEntry, + updatedAtMs: timestampMs + 1, + } satisfies CacheEntry; + const transport = new CacheQueryTransport({ + "cache.getEntry": [systemHostEntry, remountedEntry], + }); + const queryClient = createDashboardQueryClient(); + const options = cacheEntryQueryOptions( + createDashboardTrpcClient(transport), + systemHostKey + ); + const firstObserver = new QueryObserver(queryClient, options); + const firstResult = Promise.withResolvers(); + const unsubscribeFirst = firstObserver.subscribe((result) => { + if (result.data?.updatedAtMs === timestampMs) firstResult.resolve(); + }); + + try { + await firstResult.promise; + unsubscribeFirst(); + expect(queryClient.getQueryData(options.queryKey)).toEqual(systemHostEntry); + + const remountedResult = Promise.withResolvers(); + const secondObserver = new QueryObserver(queryClient, options); + const unsubscribeSecond = secondObserver.subscribe((result) => { + if ( + result.data?.updatedAtMs === timestampMs + 1 && + transport.calls.length === 2 + ) { + remountedResult.resolve(); + } + }); + try { + await remountedResult.promise; + expect(transport.calls).toHaveLength(2); + } finally { + unsubscribeSecond(); + } + } finally { + unsubscribeFirst(); + queryClient.clear(); + } + }); + + test("retains last-known-good exact data when a background refresh fails", async () => { + const transport = new CacheQueryTransport({ + "cache.getEntry": [ + systemHostEntry, + new TypeError("cache transport unavailable"), + ], + }); + const queryClient = createDashboardQueryClient(); + const options = cacheEntryQueryOptions( + createDashboardTrpcClient(transport), + systemHostKey + ); + + try { + await queryClient.fetchQuery(options); + await queryClient.invalidateQueries({ + exact: true, + queryKey: options.queryKey, + }); + const failure = await queryClient + .fetchQuery({ ...options, retry: false }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(TypeError); + expect(queryClient.getQueryData(options.queryKey)).toEqual(systemHostEntry); + } finally { + queryClient.clear(); + } + }); + + test("forwards TanStack cancellation to an in-flight exact request", async () => { + const started = Promise.withResolvers(); + let requestSignal: AbortSignal | undefined; + const transport: DashboardTrpcTransport = { + mutation(path) { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + }, + query(path, _input, options) { + if (path !== "cache.getEntry") { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + requestSignal = options?.signal; + started.resolve(); + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true } + ); + }); + }, + }; + const queryClient = createDashboardQueryClient(); + const key = cacheEntryQueryKey(systemHostKey); + const request = queryClient + .fetchQuery( + cacheEntryQueryOptions( + createDashboardTrpcClient(transport), + systemHostKey + ) + ) + .catch(() => {}); + + try { + await started.promise; + await queryClient.cancelQueries({ exact: true, queryKey: key }); + expect(requestSignal?.aborted).toBeTrue(); + await request; + } finally { + queryClient.clear(); + } + }); +}); diff --git a/greenfield/src/browser/cache/cacheQueries.ts b/greenfield/src/browser/cache/cacheQueries.ts new file mode 100644 index 000000000..16481e837 --- /dev/null +++ b/greenfield/src/browser/cache/cacheQueries.ts @@ -0,0 +1,101 @@ +import { queryOptions, type QueryClient } from "@tanstack/react-query"; + +import type { CacheEntry, CacheStatusResult } from "../../contracts/cache.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +export const cacheQueryKey = ["cache"] as const; +export const cacheStatusQueryKey = [...cacheQueryKey, "status"] as const; +export const cacheEntryQueryRoot = [...cacheQueryKey, "entries"] as const; + +/** + * Cache freshness changes as wall time advances even when no provider event occurs. + * Keep one bounded status snapshot moving while the cache browser is mounted. + */ +export const cacheStatusRefreshIntervalMs = 30_000; + +/** + * @param key Canonical provider-owned cache identity. + * @returns Exact query key for one payload-bearing cache entry. + */ +export function cacheEntryQueryKey(key: string) { + return [...cacheEntryQueryRoot, key] as const; +} + +/** @returns Bounded payload-free cache inventory query options. */ +export function cacheStatusQueryOptions(client: DashboardTrpcClient) { + return queryOptions({ + queryFn: ({ signal }): Promise => + client.query("cache.getStatus", {}, { signal }), + queryKey: cacheStatusQueryKey, + refetchInterval: cacheStatusRefreshIntervalMs, + refetchOnMount: "always", + staleTime: cacheStatusRefreshIntervalMs, + }); +} + +/** + * Builds an exact payload query only after the browser has selected an entry. + * @param client Validated browser tRPC client. + * @param key Selected provider-owned cache identity. + * @returns Exact cache-entry query options. + */ +export function cacheEntryQueryOptions(client: DashboardTrpcClient, key: string) { + return queryOptions({ + queryFn: ({ signal }): Promise => + client.query("cache.getEntry", { key }, { signal }), + queryKey: cacheEntryQueryKey(key), + refetchInterval: cacheStatusRefreshIntervalMs, + refetchOnMount: "always", + staleTime: cacheStatusRefreshIntervalMs, + }); +} + +/** @param queryClient Browser cache whose bounded status snapshot changed. */ +export async function refreshCacheStatusQueries(queryClient: QueryClient): Promise { + await queryClient.invalidateQueries({ + exact: true, + queryKey: cacheStatusQueryKey, + }); +} + +/** + * @param queryClient Browser cache containing exact provider projections. + * @param key Provider identity whose exact projection changed. + */ +export async function refreshCacheEntryQuery( + queryClient: QueryClient, + key: string +): Promise { + await queryClient.invalidateQueries({ + exact: true, + queryKey: cacheEntryQueryKey(key), + }); +} + +/** + * Invalidates both public views of one provider attempt without evicting LKG data. + * Both invalidations are attempted even if one observer refresh rejects. + * @param queryClient Browser-owned TanStack Query cache. + * @param key Provider identity changed by a mutation or realtime event. + */ +export async function refreshCacheQueriesForEntry( + queryClient: QueryClient, + key: string +): Promise { + await Promise.allSettled([ + refreshCacheStatusQueries(queryClient), + refreshCacheEntryQuery(queryClient, key), + ]); +} + +/** + * Invalidates the bounded status snapshot and every cached exact projection. + * Used only after a terminal stream condition removes per-entry event precision. + * @param queryClient Browser-owned TanStack Query cache. + */ +export async function refreshAllCacheQueries(queryClient: QueryClient): Promise { + await Promise.allSettled([ + refreshCacheStatusQueries(queryClient), + queryClient.invalidateQueries({ queryKey: cacheEntryQueryRoot }), + ]); +} diff --git a/greenfield/src/browser/cache/useCacheRealtimeInvalidation.test.tsx b/greenfield/src/browser/cache/useCacheRealtimeInvalidation.test.tsx new file mode 100644 index 000000000..0b65f0bac --- /dev/null +++ b/greenfield/src/browser/cache/useCacheRealtimeInvalidation.test.tsx @@ -0,0 +1,205 @@ +import { describe, expect, jest, test } from "bun:test"; + +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, StrictMode } from "react"; + +import { cacheRealtimeTopic } from "../../contracts/cacheRealtime.ts"; +import type { RealtimeStreamOutput } from "../../contracts/events.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { DashboardRealtimeProvider } from "../api/realtimeContext.tsx"; +import { ControlledDashboardRealtimeClient } from "../test/realtime.ts"; +import { cacheEntryQueryKey, cacheStatusQueryKey } from "./cacheQueries.ts"; +import { + cacheRealtimeFallbackRefreshIntervalMs, + cacheRealtimeRefreshDelayMs, + useCacheRealtimeInvalidation, +} from "./useCacheRealtimeInvalidation.ts"; + +const { render } = await import("@testing-library/react"); +const systemHostKey = "system.host"; +const systemMetricsKey = "system.metrics"; +const unrelatedKey = ["monitoring", "reports", "detail", "report-1"] as const; + +function CacheRealtimeProbe() { + useCacheRealtimeInvalidation(); + return null; +} + +function cacheChange(key: string, entityId = key, id = "41"): RealtimeStreamOutput { + return { + data: { + event: { + entityId, + entityType: "cache-entry", + occurredAtMs: 1_800_000_000_000, + operation: "updated", + payload: { key }, + topic: cacheRealtimeTopic, + }, + kind: "change", + }, + id, + }; +} + +describe("cache realtime invalidation", () => { + test("coalesces matching events into status and exact-entry invalidation", async () => { + jest.useFakeTimers(); + const queryClient = createDashboardQueryClient(); + const realtimeClient = new ControlledDashboardRealtimeClient(); + const hostKey = cacheEntryQueryKey(systemHostKey); + const metricsKey = cacheEntryQueryKey(systemMetricsKey); + queryClient.setQueryData(cacheStatusQueryKey, { entries: [] }); + queryClient.setQueryData(hostKey, { key: systemHostKey }); + queryClient.setQueryData(metricsKey, { key: systemMetricsKey }); + queryClient.setQueryData(unrelatedKey, { id: "report-1" }); + const view = render( + + + + + + + + ); + + try { + expect(realtimeClient.input).toEqual({ + lastEventId: "0", + topics: [cacheRealtimeTopic], + }); + expect(realtimeClient.activeSubscriptionCount).toBe(1); + + act(() => { + realtimeClient.emit(cacheChange(systemHostKey)); + realtimeClient.emit(cacheChange(systemHostKey, systemHostKey, "42")); + }); + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeFalse(); + const unsubscribeCountBeforeRerender = realtimeClient.unsubscribeCount; + view.rerender( + + + + + + + + ); + expect(realtimeClient.unsubscribeCount).toBe(unsubscribeCountBeforeRerender); + + await act(async () => { + jest.advanceTimersByTime(cacheRealtimeRefreshDelayMs); + await Promise.resolve(); + }); + + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeTrue(); + expect(queryClient.getQueryState(hostKey)?.isInvalidated).toBeTrue(); + expect(queryClient.getQueryState(metricsKey)?.isInvalidated).toBeFalse(); + expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBeFalse(); + } finally { + view.unmount(); + expect(realtimeClient.activeSubscriptionCount).toBe(0); + queryClient.clear(); + jest.useRealTimers(); + } + }); + + test("does not use an inconsistent envelope identity for exact invalidation", async () => { + jest.useFakeTimers(); + const queryClient = createDashboardQueryClient(); + const realtimeClient = new ControlledDashboardRealtimeClient(); + const hostKey = cacheEntryQueryKey(systemHostKey); + const metricsKey = cacheEntryQueryKey(systemMetricsKey); + queryClient.setQueryData(cacheStatusQueryKey, { entries: [] }); + queryClient.setQueryData(hostKey, { key: systemHostKey }); + queryClient.setQueryData(metricsKey, { key: systemMetricsKey }); + const view = render( + + + + + + ); + + try { + await act(async () => { + realtimeClient.emit(cacheChange(systemMetricsKey, systemHostKey)); + jest.advanceTimersByTime(cacheRealtimeRefreshDelayMs); + await Promise.resolve(); + }); + + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeTrue(); + expect(queryClient.getQueryState(hostKey)?.isInvalidated).toBeFalse(); + expect(queryClient.getQueryState(metricsKey)?.isInvalidated).toBeFalse(); + } finally { + view.unmount(); + queryClient.clear(); + jest.useRealTimers(); + } + }); + + test("invalidates all exact entries immediately and periodically after resync", async () => { + jest.useFakeTimers(); + const queryClient = createDashboardQueryClient(); + const realtimeClient = new ControlledDashboardRealtimeClient(); + const hostKey = cacheEntryQueryKey(systemHostKey); + const metricsKey = cacheEntryQueryKey(systemMetricsKey); + queryClient.setQueryData(cacheStatusQueryKey, { entries: [] }); + queryClient.setQueryData(hostKey, { key: systemHostKey }); + queryClient.setQueryData(metricsKey, { key: systemMetricsKey }); + queryClient.setQueryData(unrelatedKey, { id: "report-1" }); + const view = render( + + + + + + ); + + try { + await act(async () => { + realtimeClient.requireResync(); + jest.advanceTimersByTime(cacheRealtimeRefreshDelayMs); + await Promise.resolve(); + }); + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeTrue(); + expect(queryClient.getQueryState(hostKey)?.isInvalidated).toBeTrue(); + expect(queryClient.getQueryState(metricsKey)?.isInvalidated).toBeTrue(); + expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBeFalse(); + + queryClient.setQueryData(cacheStatusQueryKey, { entries: [] }); + queryClient.setQueryData(hostKey, { key: systemHostKey }); + queryClient.setQueryData(metricsKey, { key: systemMetricsKey }); + view.rerender( + + + + + + ); + await act(async () => { + jest.advanceTimersByTime( + cacheRealtimeFallbackRefreshIntervalMs + cacheRealtimeRefreshDelayMs + ); + await Promise.resolve(); + }); + expect( + queryClient.getQueryState(cacheStatusQueryKey)?.isInvalidated + ).toBeTrue(); + expect(queryClient.getQueryState(hostKey)?.isInvalidated).toBeTrue(); + expect(queryClient.getQueryState(metricsKey)?.isInvalidated).toBeTrue(); + } finally { + view.unmount(); + queryClient.clear(); + jest.useRealTimers(); + } + }); +}); diff --git a/greenfield/src/browser/cache/useCacheRealtimeInvalidation.ts b/greenfield/src/browser/cache/useCacheRealtimeInvalidation.ts new file mode 100644 index 000000000..b173a449b --- /dev/null +++ b/greenfield/src/browser/cache/useCacheRealtimeInvalidation.ts @@ -0,0 +1,76 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +import { + cacheRealtimeIdentityMatches, + cacheRealtimeTopic, +} from "../../contracts/cacheRealtime.ts"; +import { useDashboardRealtimeHub } from "../api/realtimeContextValue.ts"; +import { useRealtimeQueryInvalidation } from "../api/useRealtimeQueryInvalidation.ts"; +import { + refreshAllCacheQueries, + refreshCacheEntryQuery, + refreshCacheStatusQueries, +} from "./cacheQueries.ts"; + +export const cacheRealtimeRefreshDelayMs = 100; +export const cacheRealtimeFallbackRefreshIntervalMs = 30_000; + +/** + * Subscribes the cache browser to precise provider invalidations. The shared + * invalidation hook owns coalescing and terminal fallback; a companion listener + * retains the provider identities represented inside each coalesced window. + */ +export function useCacheRealtimeInvalidation(): void { + const hub = useDashboardRealtimeHub(); + const pendingKeys = useRef(new Set()); + const preciseEventsUnavailable = useRef(false); + + useEffect(() => { + const keys = pendingKeys.current; + const subscription = hub.subscribe([cacheRealtimeTopic], { + onData(output) { + if (output.data.kind === "resync-required") { + preciseEventsUnavailable.current = true; + return; + } + const { event } = output.data; + if ( + event.topic === cacheRealtimeTopic && + cacheRealtimeIdentityMatches(event) + ) { + keys.add(event.payload.key); + } + }, + onError() { + preciseEventsUnavailable.current = true; + }, + }); + return () => { + keys.clear(); + subscription.unsubscribe(); + }; + }, [hub]); + + const refreshQueries = async (queryClient: QueryClient) => { + if (preciseEventsUnavailable.current) { + pendingKeys.current.clear(); + await refreshAllCacheQueries(queryClient); + return; + } + + const changedKeys = [...pendingKeys.current]; + pendingKeys.current.clear(); + await Promise.allSettled([ + refreshCacheStatusQueries(queryClient), + ...changedKeys.map((key) => refreshCacheEntryQuery(queryClient, key)), + ]); + }; + + useRealtimeQueryInvalidation({ + fallbackRefreshIntervalMs: cacheRealtimeFallbackRefreshIntervalMs, + refreshDelayMs: cacheRealtimeRefreshDelayMs, + refreshQueries, + topic: cacheRealtimeTopic, + }); +} diff --git a/greenfield/src/browser/overview/OverviewRoute.test.tsx b/greenfield/src/browser/overview/OverviewRoute.test.tsx new file mode 100644 index 000000000..5ccadb970 --- /dev/null +++ b/greenfield/src/browser/overview/OverviewRoute.test.tsx @@ -0,0 +1,400 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import type { + CacheEntry, + CacheEntryStatus, + CacheStatusResult, + RefreshCacheEntryInput, +} from "../../contracts/cache.ts"; +import type { JobRunSummary } from "../../contracts/jobModel.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardBrowserApplication } from "../application.tsx"; +import { + createDashboardBrowserCollections, + type DashboardBrowserCollections, +} from "../data/dashboardCollections.ts"; +import { createDashboardRouter } from "../router.tsx"; +import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; +import { emptyNotificationListResult } from "../test/notifications.ts"; +import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; + +const { render, screen, waitFor } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const timestampMs = Date.now(); +const hostRunId = "019fe000-0000-7000-8000-000000000001"; +const refreshRunId = "019fe000-0000-7000-8000-000000000002"; + +const authenticatedStatus: AuthStatus = { + session: { + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + userAgent: "Overview route test", + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, +}; + +const hostEntry = Object.freeze({ + consecutiveFailures: 1, + expiresAtMs: timestampMs + 60_000, + failureCode: "provider/system-host-unavailable", + failureMessage: "The latest host projection attempt failed safely.", + freshness: "fresh", + key: "system.host", + lastAttemptAtMs: timestampMs, + lastAttemptDurationMs: 250, + lastAttemptNumber: 2, + lastAttemptRunId: hostRunId, + lastAttemptStatus: "failed", + lastSuccessAtMs: timestampMs - 1000, + manualRunAvailable: true, + metadata: { internalMarker: "never-render-this-metadata" }, + payload: { + architecture: "x64", + disk: { + freeBytes: 40 * 1024 ** 3, + path: "/", + totalBytes: 100 * 1024 ** 3, + }, + hostname: "mira-vps", + memory: { + freeBytes: 2 * 1024 ** 3, + totalBytes: 8 * 1024 ** 3, + }, + platform: "linux", + release: "6.8.0", + uptimeSeconds: 183_600, + }, + schemaId: "system.host.v1", + source: "system.host", + updatedAtMs: timestampMs, +} as const satisfies CacheEntry); + +const hostStatus = Object.freeze( + (({ payload: _payload, ...status }) => status)(hostEntry) +) satisfies CacheEntryStatus; + +const missingStatus = Object.freeze({ + consecutiveFailures: 1, + failureCode: "provider/unavailable", + failureMessage: "Provider unavailable.", + freshness: "missing", + key: "weather.spydeberg", + lastAttemptAtMs: timestampMs - 500, + lastAttemptDurationMs: 100, + lastAttemptNumber: 1, + lastAttemptRunId: "019fe000-0000-7000-8000-000000000003", + lastAttemptStatus: "failed", + manualRunAvailable: false, + updatedAtMs: timestampMs - 500, +} as const satisfies CacheEntryStatus); + +const cacheStatus = Object.freeze({ + entries: [hostStatus, missingStatus], + generatedAtMs: timestampMs, + totalCount: 129, + truncated: true, +} as const satisfies CacheStatusResult); + +const queuedRefresh = Object.freeze({ + actionKey: "cache.refresh.system-host", + attemptCount: 0, + attemptLimit: 3, + availableAtMs: timestampMs, + cancellationPolicy: "cooperative", + displayName: "Refresh system host cache", + eventCount: 1, + id: refreshRunId, + priority: 0, + queuedAtMs: timestampMs, + resourceClass: "light", + resourceKeys: ["cache.system.host"], + retrySafe: true, + scheduledJobId: "cache.system-host", + scheduledJobVersion: 1, + state: "queued", + stateVersion: 1, + timeoutMs: 60_000, + triggerType: "manual", + updatedAtMs: timestampMs, +} as const satisfies JobRunSummary); + +const failedRefresh = Object.freeze({ + ...queuedRefresh, + attemptCount: 1, + eventCount: 4, + finishedAtMs: timestampMs + 1000, + firstStartedAtMs: timestampMs + 100, + lastAttemptStartedAtMs: timestampMs + 100, + state: "failed", + stateVersion: 3, + terminalCode: "provider.failed", + terminalMessage: "The provider attempt failed safely.", + updatedAtMs: timestampMs + 1000, +} as const satisfies JobRunSummary); + +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), +}); + +interface TransportCall { + readonly input: unknown; + readonly path: string; +} + +interface OverviewTransportOptions { + readonly cacheEntryOutputs?: readonly (CacheEntry | Error)[]; + readonly cacheStatusOutputs?: readonly (CacheStatusResult | Error)[]; + readonly refreshOutputs?: readonly (JobRunSummary | Error)[]; +} + +function transportOutput( + outputs: readonly unknown[], + index: number, + path: string +): Promise { + const output = outputs[Math.min(index, outputs.length - 1)]; + if (output === undefined) { + return Promise.reject(new TypeError(`Unexpected transport output: ${path}`)); + } + return output instanceof Error ? Promise.reject(output) : Promise.resolve(output); +} + +class OverviewTransport implements DashboardTrpcTransport { + readonly #cacheEntryOutputs: readonly (CacheEntry | Error)[]; + readonly #cacheStatusOutputs: readonly (CacheStatusResult | Error)[]; + readonly #refreshOutputs: readonly (JobRunSummary | Error)[]; + readonly mutationCalls: TransportCall[] = []; + readonly queryCalls: TransportCall[] = []; + + constructor(options: OverviewTransportOptions = {}) { + this.#cacheEntryOutputs = options.cacheEntryOutputs ?? [hostEntry]; + this.#cacheStatusOutputs = options.cacheStatusOutputs ?? [cacheStatus]; + this.#refreshOutputs = options.refreshOutputs ?? [queuedRefresh]; + } + + mutation(path: string, input?: unknown): Promise { + const callIndex = this.mutationCalls.filter((call) => call.path === path).length; + this.mutationCalls.push({ input, path }); + if (path === "cache.refreshEntry") { + return transportOutput(this.#refreshOutputs, callIndex, path); + } + if (path === "auth.touch") { + return Promise.resolve({ lastSeenAtMs: timestampMs }); + } + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + + query(path: string, input?: unknown): Promise { + const callIndex = this.queryCalls.filter((call) => call.path === path).length; + this.queryCalls.push({ input, path }); + switch (path) { + case "auth.status": { + return Promise.resolve(authenticatedStatus); + } + case "cache.getEntry": { + return transportOutput(this.#cacheEntryOutputs, callIndex, path); + } + case "cache.getStatus": { + return transportOutput(this.#cacheStatusOutputs, callIndex, path); + } + case "notifications.list": { + return Promise.resolve(emptyNotificationListResult); + } + default: { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + } + } +} + +const queryClients: ReturnType[] = []; +const collectionRegistries: DashboardBrowserCollections[] = []; +const mountedViews: ReturnType[] = []; + +afterEach(async () => { + for (const view of mountedViews.splice(0)) view.unmount(); + await Promise.all( + collectionRegistries.splice(0).map((collections) => collections.cleanup()) + ); + for (const queryClient of queryClients.splice(0)) queryClient.clear(); +}); + +function renderOverview(transport: OverviewTransport) { + const queryClient = createDashboardQueryClient(); + queryClients.push(queryClient); + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) + ); + return { queryClient, user: userEvent.setup() }; +} + +describe("Dashboard overview cache foundation", () => { + test("loads bounded status before exact payload and queues refresh as a job", async () => { + const transport = new OverviewTransport(); + const { user } = renderOverview(transport); + + expect( + await screen.findByRole("heading", { level: 1, name: "Mira Dashboard" }) + ).toBeTruthy(); + expect(await screen.findByText("Showing 2 of 129")).toBeTruthy(); + expect( + transport.queryCalls.filter(({ path }) => path === "cache.getEntry") + ).toHaveLength(0); + expect(screen.getAllByText("fresh").length).toBeGreaterThan(0); + expect(screen.getAllByText("failed").length).toBeGreaterThan(0); + + await user.click(screen.getByRole("button", { name: "system.host" })); + expect( + await screen.findByRole("heading", { level: 3, name: "mira-vps" }) + ).toBeTruthy(); + expect(screen.getByText("75% used · 2.0 GiB free")).toBeTruthy(); + expect(screen.queryByText("never-render-this-metadata")).toBeNull(); + await waitFor(() => + expect( + transport.queryCalls.filter(({ path }) => path === "cache.getEntry") + ).toHaveLength(1) + ); + + await user.click(screen.getByRole("button", { name: "Queue refresh" })); + await waitFor(() => + expect( + transport.mutationCalls.filter( + ({ path }) => path === "cache.refreshEntry" + ) + ).toHaveLength(1) + ); + const refreshInput = transport.mutationCalls.find( + ({ path }) => path === "cache.refreshEntry" + )?.input as RefreshCacheEntryInput; + expect(refreshInput.key).toBe("system.host"); + expect(refreshInput.idempotencyKey).toMatch(/^[0-9a-f]{32}$/u); + expect( + await screen.findByText( + "Refresh queued. Cache data changes only after the worker commits the provider attempt." + ) + ).toBeTruthy(); + const queuedRunLink = screen.getByRole("link", { name: "Open refresh run" }); + expect(queuedRunLink.getAttribute("href")).toContain(refreshRunId); + }); + + test("does not present an empty truncated snapshot as a complete inventory", async () => { + const transport = new OverviewTransport({ + cacheStatusOutputs: [ + { + entries: [], + generatedAtMs: timestampMs, + totalCount: 1, + truncated: true, + }, + ], + }); + renderOverview(transport); + + expect(await screen.findByText("Showing 0 of 1")).toBeTruthy(); + expect( + screen.getByRole("heading", { + level: 3, + name: "Cache snapshot incomplete", + }) + ).toBeTruthy(); + expect(screen.queryByText("No cache attempts yet")).toBeNull(); + expect(screen.queryByText("Select a cache entry")).toBeNull(); + }); + + test("reuses an ambiguous refresh key and presents a terminal replay accurately", async () => { + const rawFailure = new TypeError("never render this refresh transport detail"); + const transport = new OverviewTransport({ + refreshOutputs: [rawFailure, failedRefresh], + }); + const { user } = renderOverview(transport); + + await screen.findByText("Showing 2 of 129"); + await user.click(screen.getByRole("button", { name: "system.host" })); + await screen.findByRole("heading", { level: 3, name: "mira-vps" }); + await user.click(screen.getByRole("button", { name: "Queue refresh" })); + expect( + await screen.findByText( + "The cache request could not be completed. Try again." + ) + ).toBeTruthy(); + expect(screen.queryByText(rawFailure.message)).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Retry refresh request" })); + expect( + await screen.findByText( + "Refresh run failed. Open the run for reviewed details." + ) + ).toBeTruthy(); + expect(screen.queryByText(/Refresh queued/u)).toBeNull(); + const refreshInputs = transport.mutationCalls + .filter(({ path }) => path === "cache.refreshEntry") + .map(({ input }) => input as RefreshCacheEntryInput); + expect(refreshInputs).toHaveLength(2); + expect(refreshInputs[1]?.idempotencyKey).toBe(refreshInputs[0]?.idempotencyKey); + expect( + screen.getByRole("link", { name: "Open refresh run" }).getAttribute("href") + ).toContain(refreshRunId); + }); + + test("clears accepted refresh feedback after the exact cache attempt advances", async () => { + const completedEntry = { + ...hostEntry, + lastAttemptRunId: refreshRunId, + } satisfies CacheEntry; + const transport = new OverviewTransport({ + cacheEntryOutputs: [hostEntry, completedEntry], + }); + const { user } = renderOverview(transport); + + await screen.findByText("Showing 2 of 129"); + await user.click(screen.getByRole("button", { name: "system.host" })); + await screen.findByRole("heading", { level: 3, name: "mira-vps" }); + await user.click(screen.getByRole("button", { name: "Queue refresh" })); + await waitFor(() => + expect( + transport.queryCalls.filter(({ path }) => path === "cache.getEntry") + ).toHaveLength(2) + ); + await waitFor(() => + expect(screen.queryByRole("link", { name: "Open refresh run" })).toBeNull() + ); + expect( + screen.queryByText( + "Refresh queued. Cache data changes only after the worker commits the provider attempt." + ) + ).toBeNull(); + }); +}); diff --git a/greenfield/src/browser/overview/OverviewRoute.tsx b/greenfield/src/browser/overview/OverviewRoute.tsx new file mode 100644 index 000000000..e2c13ea4e --- /dev/null +++ b/greenfield/src/browser/overview/OverviewRoute.tsx @@ -0,0 +1,18 @@ +import { CacheBrowser } from "../cache/CacheBrowser.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; + +/** @returns Progressive operational overview composed only from implemented domains. */ +export function OverviewRoute() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/greenfield/src/browser/routes/overview.lazy.tsx b/greenfield/src/browser/routes/overview.lazy.tsx index 50ce1fd25..34f9ac7be 100644 --- a/greenfield/src/browser/routes/overview.lazy.tsx +++ b/greenfield/src/browser/routes/overview.lazy.tsx @@ -1,52 +1,13 @@ import { createLazyRoute } from "@tanstack/react-router"; -import { ShieldCheck } from "lucide-react"; import { AuthenticationBoundary } from "../auth/AuthenticationBoundary.tsx"; -import { ActionLink } from "../ui/ActionLink.tsx"; -import { Card } from "../ui/Card.tsx"; -import { Heading } from "../ui/Heading.tsx"; -import { Icon } from "../ui/Icon.tsx"; -import { PageHeader } from "../ui/PageHeader.tsx"; -import { Text } from "../ui/Text.tsx"; +import { OverviewRoute } from "../overview/OverviewRoute.tsx"; export const Route = createLazyRoute("/")({ - component: function OverviewRoute() { + component: function OverviewRouteBoundary() { return ( -
- - - - -
- Application shell ready - - Authentication, account security, delivery, and - runtime boundaries are composed through the greenfield - application. - -
-
-
-
- - - Manage account security - -
-
+
); }, diff --git a/greenfield/src/browser/ui/EmptyState.tsx b/greenfield/src/browser/ui/EmptyState.tsx index 2806fa91c..8fece4fe4 100644 --- a/greenfield/src/browser/ui/EmptyState.tsx +++ b/greenfield/src/browser/ui/EmptyState.tsx @@ -11,6 +11,7 @@ interface EmptyStateProps { readonly action?: ReactNode; readonly className?: string; readonly description?: ReactNode; + readonly headingLevel?: 1 | 2 | 3; readonly icon?: LucideIcon; readonly title: ReactNode; } @@ -23,13 +24,18 @@ export function EmptyState({ action, className, description, + headingLevel = 2, icon = Inbox, title, }: EmptyStateProps) { return ( - + {title} {description !== undefined && ( diff --git a/greenfield/src/browser/ui/PageState.tsx b/greenfield/src/browser/ui/PageState.tsx index 0c0cfe6f1..912f78920 100644 --- a/greenfield/src/browser/ui/PageState.tsx +++ b/greenfield/src/browser/ui/PageState.tsx @@ -21,6 +21,7 @@ type PageStateProps = }> | Readonly<{ message: ReactNode; + headingLevel?: 1 | 2 | 3; onRetry?: () => void; retryBusy?: boolean; retryLabel?: string; @@ -30,6 +31,7 @@ type PageStateProps = | Readonly<{ action?: ReactNode; description?: ReactNode; + headingLevel?: 1 | 2 | 3; icon?: LucideIcon; status: "empty"; title: ReactNode; @@ -53,6 +55,7 @@ export function PageState(properties: PageStateProps) { @@ -72,7 +75,11 @@ export function PageState(properties: PageStateProps) { tone="danger" />
- + {properties.title ?? "Dashboard unavailable"} {properties.message} diff --git a/greenfield/src/test/parity/fixtures/legacy-endpoints.json b/greenfield/src/test/parity/fixtures/legacy-endpoints.json index 95b08985a..4b468d2b1 100644 --- a/greenfield/src/test/parity/fixtures/legacy-endpoints.json +++ b/greenfield/src/test/parity/fixtures/legacy-endpoints.json @@ -289,7 +289,7 @@ "delivery": "planned", "kind": "procedure", "names": ["cache.getHeartbeat"], - "phase": "phase-3" + "phase": "phase-4" } }, { @@ -368,7 +368,7 @@ "delivery": "planned", "kind": "procedure", "names": ["openClawCron.list"], - "phase": "phase-3" + "phase": "phase-4" } }, { @@ -1429,7 +1429,7 @@ "delivery": "planned", "kind": "procedure", "names": ["openClawCron.delete"], - "phase": "phase-3" + "phase": "phase-4" } }, { @@ -1442,7 +1442,7 @@ "delivery": "planned", "kind": "procedure", "names": ["openClawCron.run"], - "phase": "phase-3" + "phase": "phase-4" } }, { @@ -1455,7 +1455,7 @@ "delivery": "planned", "kind": "procedure", "names": ["openClawCron.setEnabled"], - "phase": "phase-3" + "phase": "phase-4" } }, { @@ -1468,7 +1468,7 @@ "delivery": "planned", "kind": "procedure", "names": ["openClawCron.update"], - "phase": "phase-3" + "phase": "phase-4" } }, { diff --git a/greenfield/src/test/parity/parityInventory.test.ts b/greenfield/src/test/parity/parityInventory.test.ts index 549d79f2d..9b68ce4a8 100644 --- a/greenfield/src/test/parity/parityInventory.test.ts +++ b/greenfield/src/test/parity/parityInventory.test.ts @@ -75,8 +75,8 @@ describe("reviewed pre-cutover parity inventory", () => { expect(countByPhase(legacyEndpoints.endpoints)).toEqual({ "phase-1": 7, "phase-2": 28, - "phase-3": 45, - "phase-4": 7, + "phase-3": 39, + "phase-4": 13, "phase-5": 70, }); });