diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml deleted file mode 100644 index 198a8aa..0000000 --- a/.forgejo/workflows/dimensionlab-website.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Dimension Lab website - -on: - pull_request: - types: - - opened - - synchronize - - reopened - push: - branches: - - main - workflow_dispatch: - -concurrency: - group: dimensionlab-website-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - ci: - runs-on: docker - timeout-minutes: 30 - steps: - - name: Checkout - uses: https://data.forgejo.org/actions/checkout@v4 - with: - fetch-depth: 0 - submodules: false - - - name: Initialize submodules - run: | - git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" - git submodule update --init --recursive - - - name: Install Bun - run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14" - "$HOME/.bun/bin/bun" --version - - - name: Check, test, and build - run: | - export BUN_INSTALL="$HOME/.bun" - export PATH="$BUN_INSTALL/bin:$PATH" - bun install --frozen-lockfile - bun run check - bun run test - bun run build - - deploy: - needs: ci - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: deploy - timeout-minutes: 30 - steps: - - name: Checkout - run: | - if [ -d .git ]; then - git remote set-url origin git@git.dimensionlab.net:vince/dimensionlab-website.git - else - git init - git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git - fi - git fetch --force --prune --depth=1 origin "$GITHUB_SHA" - git checkout --force --detach "$GITHUB_SHA" - git clean -ffdx - - - name: Initialize submodules - run: | - git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" - git submodule update --init --recursive - - - name: Verify Podman deployment socket - run: | - command -v podman - command -v systemctl - unit="$(timeout 15s podman inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')" - test "$unit" = "dimensionlab-website.service" - - - name: Deploy production website - env: - DEPLOY_CONTAINER_CLI: podman - DEPLOY_EVENT_NAME: ${{ github.event_name }} - DEPLOY_REF: ${{ github.ref }} - DEPLOY_RESTART_STRATEGY: quadlet-container - DEPLOY_SHA: ${{ github.sha }} - run: scripts/deploy-dimensionlab-website.sh diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 32d1c14..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "packages/ui"] - path = packages/ui - url = ssh://git@git.dimensionlab.net/vince/dimensionlab-ui.git - branch = main diff --git a/README.md b/README.md index 85177c5..fee9443 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,14 @@ validated dashboard model state inside the web app. persistence, Playwright e2e checks, and container build. - `packages/dashboard-model`: reusable dashboard schema, validation, and generic model fixtures shared by apps and tooling. -- `packages/ui`: Git submodule for the reusable dashboard React components, - design tokens, shadcn/radix primitives, generic fixtures, and Storybook. - Component source is grouped under `foundation`, `frames`, `operations`, and - `telemetry` domains. +- `packages/ui`: reusable dashboard React components, design tokens, + shadcn/radix primitives, generic fixtures, and Storybook. Component source is + grouped under `foundation`, `frames`, `operations`, and `telemetry` domains. - `docs/superpowers`: migration specs and execution plans used for this repo. ## Development ```sh -git submodule update --init --recursive bun install bun run dev ``` @@ -129,7 +127,6 @@ The production build emits Vite client assets under `apps/web/dist/` and a Bun server entry at `apps/web/build/index.js`. A minimal deployment flow is: ```sh -git submodule update --init --recursive bun install --frozen-lockfile bun run build cd apps/web @@ -161,27 +158,3 @@ The env file must provide `AGENT_CONFIG_TOKEN`. Runtime defaults inside the image set `HOST=0.0.0.0`, `PORT=3000`, `DATABASE_URL=file:/data/dimensionlab.sqlite`, and `DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle`. - -### Forgejo Actions Deployment - -Merges to `main` run `.forgejo/workflows/dimensionlab-website.yml`. Pull -requests run check, test, and build only; the deploy job is guarded to run only -for `push` events on `refs/heads/main`. - -The workflow uses two runner classes. Pull request CI runs on the containerized -`docker` runner. Production deployment runs on a separate host runner with the -`deploy:host` label so the guarded deploy script can use the user's rootless -`podman` and `systemctl --user` commands directly. The deploy job uses a -shell-only `git fetch` checkout so the host runner does not need a Node runtime -for checkout actions. - -```yaml -runner: - labels: - - deploy:host -``` - -The deploy job also performs a host preflight against the -`dimensionlab-website.service` Podman label before it builds or restarts the -production container. Do not give the general pull request runner deployment -socket access; keep deploy privileges on the dedicated `deploy` runner. diff --git a/apps/web/Containerfile b/apps/web/Containerfile index 32387a4..9ec2baa 100644 --- a/apps/web/Containerfile +++ b/apps/web/Containerfile @@ -11,7 +11,7 @@ RUN bunx turbo prune @dimensionlab/web --docker FROM base AS deps COPY --from=pruner /repo/out/json/ ./ -RUN bun install --frozen-lockfile --ignore-scripts +RUN bun install --frozen-lockfile FROM deps AS build diff --git a/apps/web/package.json b/apps/web/package.json index 015fc55..790663c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,8 +8,8 @@ "build": "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", "preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js", "check": "tsc --noEmit", - "test": "bun --bun vitest run", - "test:unit": "bun --bun vitest run", + "test": "vitest run", + "test:unit": "vitest run", "test:e2e": "env -u NO_COLOR playwright test", "db:generate": "drizzle-kit generate", "db:check": "drizzle-kit check" diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index c1add67..fbb133d 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,13 +1,6 @@ import { renderToString } from "react-dom/server"; import { describe, expect, test } from "vitest"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { - AppStateView, - dashboardTileMatchKey, - dashboardHydrationTiles, - restoreDashboardTileSnapshots, -} from "./App"; +import { AppStateView } from "./App"; describe("React app dashboard state view", () => { test("renders loading dashboard state", () => { @@ -48,191 +41,4 @@ describe("React app dashboard state view", () => { expect(html).toContain("Dark"); expect(html).toContain("Light"); }); - - test("renders the dashboard shell while individual items hydrate", () => { - const html = renderToString( - , - ); - - expect(html).toContain("Operations Console"); - expect(html).toContain("Service Uptime"); - expect(html).toContain("Identity"); - expect(html).toContain("Environment"); - expect(html).not.toContain("Loading Dashboard"); - expect(html).toContain( - 'data-severity="loading" data-model-id="service-uptime"', - ); - expect(html).toContain('data-severity="loading" data-model-id="identity"'); - expect(html).toContain('data-severity="loading" data-model-id="ambient"'); - expect(html).toContain('data-severity="loading" data-model-id="runtime:status"'); - }); - - test("hydrates every status cell from the Dimension Lab shell", () => { - expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({ - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }); - }); - - test("prioritizes status, telemetry, modules, then services for hydration", () => { - const tiles = dashboardHydrationTiles(dimensionLabDashboardFixture); - const kinds = tiles.map((tile) => tile.kind); - const firstServiceIndex = kinds.indexOf("service"); - - expect(kinds.slice(0, 5)).toEqual([ - "status", - "status", - "status", - "status", - "status", - ]); - expect(kinds.lastIndexOf("telemetry")).toBeLessThan(kinds.indexOf("module")); - expect(kinds.lastIndexOf("module")).toBeLessThan(firstServiceIndex); - expect( - tiles.filter((tile) => tile.kind === "telemetry").map((tile) => tile.id), - ).toEqual( - dimensionLabDashboardFixture.telemetry - .filter((card) => card.datasource?.type === "external") - .map((card) => card.id), - ); - expect( - tiles.filter((tile) => tile.kind === "service").map((tile) => ({ - groupId: tile.groupId, - id: tile.id, - })), - ).toEqual( - dimensionLabDashboardFixture.serviceGroups.flatMap((group) => - group.services - .filter((service) => service.datasource?.type === "external") - .map((service) => ({ - groupId: group.id, - id: service.id, - })) - ), - ); - }); - - test("applies restored tile snapshots without marking them as loading", () => { - const restored = restoreDashboardTileSnapshots( - { - state: "ready", - document: dimensionLabDashboardFixture, - schemaVersion: "dashboard.v1", - currentRevisionId: "revision-a", - }, - [ - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - ...dimensionLabDashboardFixture.telemetry[0], - detail: "cached - stale 60s", - severity: "stale", - }, - }, - }, - ], - ); - - expect(restored.restoredItemIds).toEqual(new Set(["telemetry:infra-ram"])); - if (restored.dashboard.state !== "ready") { - throw new Error("Expected dashboard to be ready"); - } - expect(restored.dashboard.document.telemetry[0]).toMatchObject({ - id: "infra-ram", - detail: "cached - stale 60s", - severity: "stale", - }); - }); - - test("does not restore aggregate health snapshots over the fresh shell", () => { - const restored = restoreDashboardTileSnapshots( - { - state: "ready", - document: dimensionLabDashboardFixture, - schemaVersion: "dashboard.v1", - currentRevisionId: "revision-a", - }, - [ - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "status", stripId: "footer-status", id: "system-status" }, - item: { - id: "system-status", - label: "System Status", - value: "20 services down", - severity: "stale", - }, - }, - }, - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "module", id: "runtime-health-summary" }, - item: { - id: "runtime-health-summary", - kind: "summary", - title: "Runtime Health", - value: "20 services down", - detail: "0 warnings - 8 services ok - stale 60s", - severity: "stale", - }, - }, - }, - ], - ); - - expect(restored.restoredItemIds).toEqual(new Set()); - if (restored.dashboard.state !== "ready") { - throw new Error("Expected dashboard to be ready"); - } - expect( - restored.dashboard.document.statusStrips[0].items.find((item) => - item.id === "system-status" - ), - ).toMatchObject({ - id: "system-status", - value: "Fallback operational", - }); - expect( - restored.dashboard.document.modules?.find((module) => - module.id === "runtime-health-summary" - ), - ).toMatchObject({ - id: "runtime-health-summary", - value: "fallback", - }); - }); - - test("uses structured tile match keys for delimiter-bearing ids", () => { - expect( - dashboardTileMatchKey({ kind: "service", groupId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileMatchKey({ kind: "service", groupId: "a", id: "b:c" }), - ); - expect( - dashboardTileMatchKey({ kind: "status", stripId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileMatchKey({ kind: "status", stripId: "a", id: "b:c" }), - ); - }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 403bef9..0099173 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,30 +1,5 @@ import { useEffect, useState } from "react"; -import type { - DashboardDocument, - DashboardModule, - ServiceEntry, - StatusItem, - TelemetryCard, -} from "@dimensionlab/dashboard-model"; import type { DashboardRuntimeState } from "$lib/server/dashboard"; -import { - attachDashboardRefreshLifecycle, - collectVisibleDashboardModelIds, - createDashboardPerformanceMarks, - createDashboardRefreshDelay, - createDashboardTileSnapshotStore, - createDashboardTileBackoff, - createDashboardRequestAborter, - isPersistableDashboardTileSnapshot, - runViewportAwareDashboardHydrationQueue, - shouldPauseDashboardRefresh, - subscribeToDashboardTileEvents, - waitForDashboardHydrationIdle, - type DashboardIntersectionObserverFactory, - type DashboardTileReference, - type DashboardTileSnapshotStoreContext, - type RestoredDashboardTileSnapshot, -} from "$lib/client/dashboard-refresh"; import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer"; import { DashboardFrame, @@ -34,46 +9,21 @@ import { resolveInitialUiTheme, type UiTheme, type UiSeverity, - type UiDashboardPreview, } from "@dimensionlab/ui"; -type DashboardTileResponse = - | { - state: "ready"; - tile: DashboardTileReference; - item: DashboardModule | ServiceEntry | StatusItem | TelemetryCard; - } - | { - state: "not_found"; - tile: DashboardTileReference; - message: string; - } - | { - state: "disabled"; - tile: DashboardTileReference; - message: string; - }; - -type DashboardTilesBatchResponse = { - state: "ready"; - tiles: DashboardTileResponse[]; +const loadingDashboardState: DashboardRuntimeState = { + state: "loading", + title: "Loading Dashboard", + subtitle: "Fetching active model", + message: "Waiting for the active dashboard document.", }; -type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; - -const dashboardTileHydrationConcurrency = 6; -const dashboardFallbackRefreshIntervalMs = 30_000; -const dashboardViewportObservationTimeoutMs = 80; -type DashboardTileSnapshotStore = ReturnType; - export function AppStateView({ dashboard, onThemeChange, theme, - hydratingItemIds, }: { dashboard: DashboardRuntimeState; - hydratingItemIds?: ReadonlySet; onThemeChange?: (theme: UiTheme) => void; theme?: UiTheme; }) { @@ -83,14 +33,9 @@ export function AppStateView({ ) : null; if (dashboard.state === "ready") { - const uiDashboard = markHydratingItems( - dashboardDocumentToUiDashboard(dashboard.document), - hydratingItemIds, - ); - return ( ); @@ -144,10 +89,7 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): { export default function App() { const [dashboard, setDashboard] = - useState(null); - const [hydratingItemIds, setHydratingItemIds] = useState>( - () => new Set(), - ); + useState(loadingDashboardState); const [theme, setTheme] = useState(() => { if (typeof window === "undefined") return "dark"; @@ -155,8 +97,6 @@ export default function App() { }); useEffect(() => { - if (!dashboard) return; - const metadata = resolveDocumentMetadata(dashboard); document.title = metadata.title; @@ -179,404 +119,47 @@ export default function App() { useEffect(() => { let cancelled = false; let refreshTimer: number | undefined; - let unsubscribeTileEvents: (() => void) | undefined; - let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs; - let hydrationRun = 0; - const requestAborter = createDashboardRequestAborter(); - const performanceMarks = createDashboardPerformanceMarks(); - const refreshDelay = createDashboardRefreshDelay(); - const tileBackoff = createDashboardTileBackoff(); - const tileSnapshotStore = createDashboardTileSnapshotStore( - getTileSnapshotStorage(), - ); - - function refreshPaused() { - return shouldPauseDashboardRefresh({ - visibilityState: document.visibilityState, - online: navigator.onLine, - }); - } - - function clearRefreshTimer() { - if (refreshTimer !== undefined) { - window.clearTimeout(refreshTimer); - refreshTimer = undefined; - } - } - - function scheduleNextRefresh(baseDelayMs: number) { - clearRefreshTimer(); - if (cancelled || refreshPaused()) return; - - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined; - void loadDashboard(); - }, refreshDelay.nextDelayMs(baseDelayMs)); - } - - function pauseRefreshes() { - clearRefreshTimer(); - unsubscribeTileEvents?.(); - unsubscribeTileEvents = undefined; - requestAborter.abortActiveRequests(); - setHydratingItemIds(new Set()); - } async function loadDashboard() { - if (cancelled || refreshPaused()) return; + const response = await fetch("/api/dashboard"); + const nextDashboard = (await response.json()) as DashboardRuntimeState; - clearRefreshTimer(); - const shellSignal = requestAborter.beginShellRun(); + if (cancelled) return; + setDashboard(nextDashboard); - try { - const response = await fetch("/api/dashboard", { signal: shellSignal }); - const nextDashboard = (await response.json()) as DashboardRuntimeState; - - if (cancelled || shellSignal.aborted || refreshPaused()) return; - refreshDelay.recordSuccess(); - performanceMarks.markShellLoad(); - const restored = restoreDashboardTileSnapshots( - nextDashboard, - nextDashboard.state === "ready" - ? tileSnapshotStore.restore({ - currentRevisionId: nextDashboard.currentRevisionId, - schemaVersion: nextDashboard.schemaVersion, - }) - : [], - ); - setDashboard(restored.dashboard); - - const currentRun = ++hydrationRun; - if ( - restored.dashboard.state === "ready" && - restored.dashboard.liveDatasourceHydration?.enabled !== false - ) { - const tiles = dashboardHydrationTiles(restored.dashboard.document).filter( - (tile) => tileBackoff.canAttempt(dashboardTileKey(tile)), - ); - const tileSignal = requestAborter.beginTileRun(); - setHydratingItemIds( - new Set( - tiles - .map(dashboardTileKey) - .filter((key) => !restored.restoredItemIds.has(key)), - ), - ); - hydrateDashboardTiles( - tiles, - currentRun, - tileSignal, - { - currentRevisionId: restored.dashboard.currentRevisionId, - schemaVersion: restored.dashboard.schemaVersion, - }, - ); - subscribeDashboardTileEvents( - currentRun, - tileSignal, - { - currentRevisionId: restored.dashboard.currentRevisionId, - schemaVersion: restored.dashboard.schemaVersion, - }, - ); - } else { - setHydratingItemIds(new Set()); - } - - const refreshIntervalSeconds = - nextDashboard.state === "ready" - ? nextDashboard.document.metadata.refreshIntervalSeconds - : undefined; - - if (refreshIntervalSeconds && !refreshPaused()) { - lastRefreshIntervalMs = refreshIntervalSeconds * 1000; - scheduleNextRefresh(lastRefreshIntervalMs); - } - } catch (error) { - if (!isAbortError(error) && !cancelled) { - refreshDelay.recordFailure(); - console.error("Dashboard refresh failed", error); - scheduleNextRefresh(lastRefreshIntervalMs); - } + if (refreshTimer) { + window.clearInterval(refreshTimer); + refreshTimer = undefined; } - } - function hydrateDashboardTiles( - tiles: DashboardTileReference[], - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ) { - const modelIds = tiles.map(dashboardTileModelId); + const refreshIntervalSeconds = + nextDashboard.state === "ready" + ? nextDashboard.document.metadata.refreshIntervalSeconds + : undefined; - void runViewportAwareDashboardHydrationQueue({ - batchSize: dashboardTileHydrationConcurrency, - collectVisibleModelIds: async () => { - await waitForDashboardRenderFrame(signal); - return collectVisibleDashboardModelIds({ - clearTimeout: window.clearTimeout.bind(window), - createObserver: getDashboardIntersectionObserverFactory(), - documentTarget: document, - modelIds, - setTimeout: window.setTimeout.bind(window), - signal, - timeoutMs: dashboardViewportObservationTimeoutMs, - }); - }, - concurrency: dashboardTileHydrationConcurrency, - getModelId: dashboardTileModelId, - hydrate: async (tile) => { - const key = dashboardTileKey(tile); - const result = await hydrateDashboardTile( - tile, - run, - signal, - snapshotContext, - ); - - if (result === "ready") { - tileBackoff.recordSuccess(key); - } else if (result === "failed") { - tileBackoff.recordFailure(key); - } - }, - hydrateBatch: async (batch) => { - const results = await hydrateDashboardTileBatch( - batch, - run, - signal, - snapshotContext, - ); - - for (const { result, tile } of results) { - const key = dashboardTileKey(tile); - if (result === "ready") { - tileBackoff.recordSuccess(key); - } else if (result === "failed") { - tileBackoff.recordFailure(key); - } - } - }, - items: tiles, - onAllItemsSettled: () => performanceMarks.markAllTilesSettled(), - onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(), - signal, - waitForIdle: () => - waitForDashboardHydrationIdle({ - ...getDashboardIdleCallbacks(), - clearTimeout: window.clearTimeout.bind(window), - setTimeout: window.setTimeout.bind(window), - signal, - }), - }); - } - - function subscribeDashboardTileEvents( - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ) { - unsubscribeTileEvents?.(); - unsubscribeTileEvents = subscribeToDashboardTileEvents({ - onTile: (event) => { - const tileResponse = event as DashboardTileResponse; - if (!isDashboardTileResponse(tileResponse)) return; - applyDashboardTileHydrationResponse( - tileResponse.tile, - tileResponse, - run, - signal, - snapshotContext, - ); - }, - onUnavailable: () => { - unsubscribeTileEvents = undefined; - }, - }); - } - - async function hydrateDashboardTileBatch( - tiles: DashboardTileReference[], - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ): Promise> { - try { - const response = await fetch("/api/dashboard/tiles", { - body: JSON.stringify({ tiles }), - headers: { "Content-Type": "application/json" }, - method: "POST", - signal, - }); - const batchResponse = (await response.json()) as DashboardTilesBatchResponse; - - if ( - cancelled || - signal.aborted || - run !== hydrationRun || - !response.ok || - batchResponse.state !== "ready" - ) { - throw new Error("Dashboard tile batch hydration failed"); - } - - const responsesByKey = new Map( - batchResponse.tiles.map((tileResponse) => [ - dashboardTileMatchKey(tileResponse.tile), - tileResponse, - ]), - ); - - return tiles.map((tile) => { - const key = dashboardTileKey(tile); - try { - return { - tile, - result: applyDashboardTileHydrationResponse( - tile, - responsesByKey.get(dashboardTileMatchKey(tile)), - run, - signal, - snapshotContext, - ), - }; - } finally { - finishDashboardTileHydration(key, run, signal); - } - }); - } catch (error) { - if (signal.aborted || cancelled || run !== hydrationRun) { - return tiles.map((tile) => ({ result: "aborted", tile })); - } - - return Promise.all( - tiles.map(async (tile) => ({ - tile, - result: await hydrateDashboardTile(tile, run, signal, snapshotContext), - })), + if (refreshIntervalSeconds) { + refreshTimer = window.setInterval( + () => void loadDashboard(), + refreshIntervalSeconds * 1000, ); } } - async function hydrateDashboardTile( - tile: DashboardTileReference, - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ): Promise { - const key = dashboardTileKey(tile); - - try { - const response = await fetch(dashboardTileUrl(tile), { signal }); - const tileResponse = (await response.json()) as DashboardTileResponse; - - if (!response.ok) { - return "failed"; - } - - const result = applyDashboardTileHydrationResponse( - tile, - tileResponse, - run, - signal, - snapshotContext, - ); - if (result !== "ready") { - return signal.aborted || cancelled || run !== hydrationRun - ? "aborted" - : "failed"; - } - - return "ready"; - } catch (error) { - if (!isAbortError(error) && !cancelled) { - console.error("Dashboard tile hydration failed", error); - return "failed"; - } - return "aborted"; - } finally { - finishDashboardTileHydration(key, run, signal); - } - } - - function applyDashboardTileHydrationResponse( - tile: DashboardTileReference, - tileResponse: DashboardTileResponse | undefined, - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ): DashboardTileHydrationResult { - if ( - cancelled || - signal.aborted || - run !== hydrationRun || - !tileResponse || - dashboardTileMatchKey(tileResponse.tile) !== dashboardTileMatchKey(tile) || - tileResponse.state !== "ready" - ) { - return signal.aborted || cancelled || run !== hydrationRun - ? "aborted" - : "failed"; - } - - const readyTileResponse = tileResponse; - setDashboard((current) => - current?.state === "ready" - ? { - ...current, - document: applyDashboardTile(current.document, readyTileResponse), - } - : current, - ); - tileSnapshotStore.saveReadyTile({ - ...snapshotContext, - response: readyTileResponse, - }); - performanceMarks.markFirstTileReady(); - return "ready"; - } - - function finishDashboardTileHydration( - key: string, - run: number, - signal: AbortSignal, - ) { - if (!cancelled && !signal.aborted && run === hydrationRun) { - setHydratingItemIds((current) => { - const next = new Set(current); - next.delete(key); - return next; - }); - } - } - - const detachRefreshLifecycle = attachDashboardRefreshLifecycle({ - documentTarget: document, - windowTarget: window, - loadDashboard, - pauseRefreshes, - refreshPaused: () => cancelled || refreshPaused(), - }); - void loadDashboard(); return () => { cancelled = true; - clearRefreshTimer(); - unsubscribeTileEvents?.(); - requestAborter.abortActiveRequests(); - detachRefreshLifecycle(); + if (refreshTimer) window.clearInterval(refreshTimer); }; }, []); - return dashboard ? ( + return ( - ) : null; + ); } function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { @@ -598,278 +181,3 @@ function getThemeStorage(): Storage | undefined { return undefined; } } - -function getTileSnapshotStorage(): Storage | undefined { - try { - return window.localStorage; - } catch { - return undefined; - } -} - -type DashboardIdleWindow = Window & { - cancelIdleCallback?: (handle: number) => void; - requestIdleCallback?: ( - callback: () => void, - options?: { timeout?: number }, - ) => number; -}; - -function getDashboardIdleCallbacks() { - const idleWindow = window as DashboardIdleWindow; - return { - cancelIdleCallback: idleWindow.cancelIdleCallback?.bind(idleWindow), - requestIdleCallback: idleWindow.requestIdleCallback?.bind(idleWindow), - }; -} - -function getDashboardIntersectionObserverFactory(): - | DashboardIntersectionObserverFactory - | undefined { - if (typeof window.IntersectionObserver === "undefined") return undefined; - - return (callback) => - new window.IntersectionObserver((entries) => { - callback(entries); - }); -} - -async function waitForDashboardRenderFrame(signal: AbortSignal): Promise { - if (signal.aborted) return; - - await new Promise((resolve) => { - let settled = false; - let frame: number | undefined; - - function finish() { - if (settled) return; - settled = true; - if (frame !== undefined) window.cancelAnimationFrame(frame); - signal.removeEventListener("abort", finish); - resolve(); - } - - signal.addEventListener("abort", finish, { once: true }); - frame = window.requestAnimationFrame(finish); - }); -} - -function isAbortError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "name" in error && - error.name === "AbortError" - ); -} - -function isDashboardTileResponse(value: unknown): value is DashboardTileResponse { - return ( - typeof value === "object" && - value !== null && - "state" in value && - (value.state === "ready" || - value.state === "not_found" || - value.state === "disabled") && - "tile" in value && - typeof value.tile === "object" && - value.tile !== null - ); -} - -export function restoreDashboardTileSnapshots( - dashboard: DashboardRuntimeState, - snapshots: RestoredDashboardTileSnapshot[], -): { - dashboard: DashboardRuntimeState; - restoredItemIds: Set; -} { - if (dashboard.state !== "ready" || snapshots.length === 0) { - return { - dashboard, - restoredItemIds: new Set(), - }; - } - - return snapshots.filter((snapshot) => - isPersistableDashboardTileSnapshot(snapshot.response.tile) - ).reduce( - (current, snapshot) => ({ - dashboard: { - ...current.dashboard, - document: applyDashboardTile( - current.dashboard.document, - snapshot.response as Extract, - ), - }, - restoredItemIds: new Set([ - ...current.restoredItemIds, - dashboardTileKey(snapshot.response.tile), - ]), - }), - { - dashboard, - restoredItemIds: new Set(), - }, - ); -} - -function markHydratingItems( - dashboard: UiDashboardPreview, - hydratingItemIds?: ReadonlySet, -): UiDashboardPreview { - if (!hydratingItemIds?.size) return dashboard; - - return { - ...dashboard, - telemetry: dashboard.telemetry.map((card) => - hydratingItemIds.has(`telemetry:${card.id}`) - ? { - ...card, - severity: "loading", - detail: "loading live telemetry", - } - : card, - ), - serviceGroups: dashboard.serviceGroups.map((group) => ({ - ...group, - services: group.services.map((service) => - hydratingItemIds.has(`service:${group.id}:${service.id}`) - ? { - ...service, - severity: "loading", - detail: "loading", - } - : service, - ), - })), - modules: dashboard.modules.map((module) => - hydratingItemIds.has(`module:${module.id}`) - ? { - ...module, - severity: "loading", - detail: "loading live data", - } - : module, - ), - statusItems: dashboard.statusItems.map((item) => - hydratingItemIds.has(`status:${item.id}`) - ? { - ...item, - severity: "loading", - value: "loading", - } - : item, - ), - }; -} - -export function dashboardHydrationTiles( - document: DashboardDocument, -): DashboardTileReference[] { - const telemetry = document.telemetry - .filter((card) => card.datasource?.type === "external") - .map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id })); - const services = document.serviceGroups.flatMap((group) => - group.services - .filter((service) => service.datasource?.type === "external") - .map((service): DashboardTileReference => ({ - kind: "service", - groupId: group.id, - id: service.id, - })), - ); - const modules = (document.modules || []) - .filter((module) => - module.datasource?.type === "external" || - module.id === "runtime-health-summary" - ) - .map((module): DashboardTileReference => ({ kind: "module", id: module.id })); - const status = document.statusStrips.flatMap((strip) => - strip.items - .map((item): DashboardTileReference => ({ - kind: "status", - stripId: strip.id, - id: item.id, - })), - ); - - return [...status, ...telemetry, ...modules, ...services]; -} - -function dashboardTileKey(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`; - if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`; - return `${tile.kind}:${tile.id}`; -} - -export function dashboardTileMatchKey(tile: DashboardTileReference): string { - return JSON.stringify(tile); -} - -function dashboardTileModelId(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.stripId}:${tile.id}`; - return tile.id; -} - -function dashboardTileUrl(tile: DashboardTileReference): string { - const parts = tile.kind === "status" - ? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id] - : tile.kind === "service" - ? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id] - : ["api", "dashboard", "tile", tile.kind, tile.id]; - return `/${parts.map(encodeURIComponent).join("/")}`; -} - -function applyDashboardTile( - document: DashboardDocument, - response: Extract, -): DashboardDocument { - if (response.tile.kind === "telemetry") { - return { - ...document, - telemetry: document.telemetry.map((card) => - card.id === response.tile.id ? response.item as TelemetryCard : card, - ), - }; - } - - if (response.tile.kind === "service") { - const tile = response.tile; - return { - ...document, - serviceGroups: document.serviceGroups.map((group) => ({ - ...group, - services: group.id === tile.groupId - ? group.services.map((service) => - service.id === tile.id ? response.item as ServiceEntry : service, - ) - : group.services, - })), - }; - } - - if (response.tile.kind === "module") { - return { - ...document, - modules: (document.modules || []).map((module) => - module.id === response.tile.id ? response.item as DashboardModule : module, - ), - }; - } - - const tile = response.tile; - return { - ...document, - statusStrips: document.statusStrips.map((strip) => - strip.id === tile.stripId - ? { - ...strip, - items: strip.items.map((item) => - item.id === tile.id ? response.item as StatusItem : item, - ), - } - : strip, - ), - }; -} diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts deleted file mode 100644 index 77ea795..0000000 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - attachDashboardRefreshLifecycle, - collectVisibleDashboardModelIds, - createDashboardPerformanceMarks, - createDashboardRefreshDelay, - dashboardPerformanceMarks, - createDashboardTileSnapshotStore, - createDashboardTileBackoff, - createDashboardRequestAborter, - runDashboardHydrationQueue, - runDashboardHydrationBatchQueue, - runViewportAwareDashboardHydrationQueue, - shouldPauseDashboardRefresh, - splitDashboardHydrationItemsByVisibility, - subscribeToDashboardTileEvents, - waitForDashboardHydrationIdle, - type DashboardIntersectionEntry, -} from "./dashboard-refresh"; - -describe("dashboard refresh lifecycle", () => { - test("pauses refreshes when the document is hidden or the browser is offline", () => { - expect( - shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }), - ).toBe(false); - expect( - shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }), - ).toBe(true); - expect( - shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }), - ).toBe(true); - }); - - test("aborts shell and tile requests when a new shell run starts", () => { - const aborter = createDashboardRequestAborter(); - const shellSignal = aborter.beginShellRun(); - const tileSignal = aborter.beginTileRun(); - - const nextShellSignal = aborter.beginShellRun(); - - expect(shellSignal.aborted).toBe(true); - expect(tileSignal.aborted).toBe(true); - expect(nextShellSignal.aborted).toBe(false); - }); - - test("aborts active requests when refreshes are paused", () => { - const aborter = createDashboardRequestAborter(); - const shellSignal = aborter.beginShellRun(); - const tileSignal = aborter.beginTileRun(); - - aborter.abortActiveRequests(); - - expect(shellSignal.aborted).toBe(true); - expect(tileSignal.aborted).toBe(true); - }); - - test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => { - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - const calls: string[] = []; - let paused = true; - - const detach = attachDashboardRefreshLifecycle({ - documentTarget, - windowTarget, - loadDashboard: () => { - calls.push("load"); - }, - pauseRefreshes: () => { - calls.push("pause"); - }, - refreshPaused: () => paused, - }); - - documentTarget.dispatchEvent(new Event("visibilitychange")); - windowTarget.dispatchEvent(new Event("offline")); - windowTarget.dispatchEvent(new Event("pagehide")); - - paused = false; - documentTarget.dispatchEvent(new Event("visibilitychange")); - windowTarget.dispatchEvent(new Event("online")); - - detach(); - documentTarget.dispatchEvent(new Event("visibilitychange")); - windowTarget.dispatchEvent(new Event("online")); - - expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]); - }); - - test("limits tile hydration concurrency", async () => { - let active = 0; - let maxActive = 0; - const started: number[] = []; - const releases = new Map void>(); - - const queue = runDashboardHydrationQueue({ - concurrency: 2, - items: [1, 2, 3, 4], - signal: new AbortController().signal, - hydrate: async (item) => { - active += 1; - maxActive = Math.max(maxActive, active); - started.push(item); - await new Promise((resolve) => releases.set(item, resolve)); - active -= 1; - }, - }); - - await waitFor(() => started.length === 2); - expect(started).toEqual([1, 2]); - expect(maxActive).toBe(2); - - releases.get(1)?.(); - await waitFor(() => started.length === 3); - expect(started).toEqual([1, 2, 3]); - expect(maxActive).toBe(2); - - releases.get(2)?.(); - releases.get(3)?.(); - await waitFor(() => started.length === 4); - releases.get(4)?.(); - await queue; - expect(maxActive).toBe(2); - }); - - test("hydrates queued work in fixed-size batches", async () => { - const batches: number[][] = []; - - await runDashboardHydrationBatchQueue({ - batchSize: 3, - hydrateBatch: (items) => { - batches.push(items); - }, - items: [1, 2, 3, 4, 5, 6, 7], - signal: new AbortController().signal, - }); - - expect(batches).toEqual([ - [1, 2, 3], - [4, 5, 6], - [7], - ]); - }); - - test("hydrates visible items before deferred items and waits for idle", async () => { - const calls: string[] = []; - const idleReleases: Array<() => void> = []; - - const hydration = runViewportAwareDashboardHydrationQueue({ - collectVisibleModelIds: async () => new Set(["b"]), - concurrency: 1, - getModelId: (item) => item, - hydrate: async (item) => { - calls.push(item); - }, - items: ["a", "b", "c"], - onAllItemsSettled: () => calls.push("all-settled"), - onVisibleItemsSettled: () => calls.push("visible-settled"), - signal: new AbortController().signal, - waitForIdle: async () => { - calls.push("idle"); - await new Promise((resolve) => idleReleases.push(resolve)); - }, - }); - - await waitFor(() => calls.includes("idle")); - expect(calls).toEqual(["b", "visible-settled", "idle"]); - - idleReleases.shift()?.(); - await hydration; - expect(calls).toEqual([ - "b", - "visible-settled", - "idle", - "a", - "c", - "all-settled", - ]); - }); - - test("splits visible hydration items while preserving document order", () => { - expect(splitDashboardHydrationItemsByVisibility({ - getModelId: (item) => item.id, - items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }], - visibleModelIds: new Set(["service", "status"]), - })).toEqual({ - visible: [{ id: "status" }, { id: "service" }], - deferred: [{ id: "telemetry" }], - }); - }); - - test("collects visible data-model-id elements with IntersectionObserver", async () => { - const elements = [ - modelElement("status"), - modelElement("telemetry"), - modelElement("unrelated"), - ]; - let callback: - | ((entries: DashboardIntersectionEntry[]) => void) - | undefined; - let finishObservation: (() => void) | undefined; - let disconnected = false; - const observed: string[] = []; - - const visible = collectVisibleDashboardModelIds({ - createObserver: (observerCallback) => { - callback = observerCallback; - return { - disconnect() { - disconnected = true; - }, - observe(element) { - observed.push(element.getAttribute("data-model-id") || ""); - }, - }; - }, - documentTarget: { - querySelectorAll: () => elements, - }, - modelIds: ["status", "telemetry"], - setTimeout: (handler) => { - finishObservation = handler; - return 1 as unknown as ReturnType; - }, - clearTimeout: () => undefined, - signal: new AbortController().signal, - }); - - callback?.([ - { - isIntersecting: false, - intersectionRatio: 0, - target: elements[0], - }, - { - isIntersecting: true, - target: elements[1], - }, - ]); - finishObservation?.(); - - expect(await visible).toEqual(new Set(["telemetry"])); - expect(observed).toEqual(["status", "telemetry"]); - expect(disconnected).toBe(true); - }); - - test("waits for requestIdleCallback when available", async () => { - let idleCallback: (() => void) | undefined; - let cancelledIdle: number | undefined; - const wait = waitForDashboardHydrationIdle({ - cancelIdleCallback: (handle) => { - cancelledIdle = handle; - }, - requestIdleCallback: (callback) => { - idleCallback = callback; - return 7; - }, - signal: new AbortController().signal, - }); - - expect(cancelledIdle).toBeUndefined(); - idleCallback?.(); - await wait; - expect(cancelledIdle).toBe(7); - }); - - test("jitters refresh delays and slows repeated failures", () => { - const delay = createDashboardRefreshDelay({ - jitterRatio: 0.1, - random: () => 1, - }); - - expect(delay.nextDelayMs(1_000)).toBe(1_100); - delay.recordFailure(); - expect(delay.nextDelayMs(1_000)).toBe(2_200); - delay.recordFailure(); - expect(delay.nextDelayMs(1_000)).toBe(4_400); - delay.recordSuccess(); - expect(delay.nextDelayMs(1_000)).toBe(1_100); - }); - - test("marks dashboard performance milestones once per shell run", () => { - const marks: string[] = []; - const performanceMarks = createDashboardPerformanceMarks({ - mark: (name) => marks.push(name), - }); - - performanceMarks.markShellLoad(); - performanceMarks.markFirstTileReady(); - performanceMarks.markFirstTileReady(); - performanceMarks.markVisibleTilesReady(); - performanceMarks.markAllTilesSettled(); - performanceMarks.markShellLoad(); - performanceMarks.markFirstTileReady(); - - expect(marks).toEqual([ - dashboardPerformanceMarks.shellLoad, - dashboardPerformanceMarks.firstTileReady, - dashboardPerformanceMarks.visibleTilesReady, - dashboardPerformanceMarks.allTilesSettled, - dashboardPerformanceMarks.shellLoad, - dashboardPerformanceMarks.firstTileReady, - ]); - }); - - test("subscribes to dashboard tile events", () => { - const received: unknown[] = []; - let listener: ((event: MessageEvent) => void) | undefined; - let closed = false; - - const unsubscribe = subscribeToDashboardTileEvents({ - createEventSource: (url) => { - expect(url).toBe("/api/dashboard/events"); - return { - addEventListener(_type, eventListener) { - listener = eventListener; - }, - close() { - closed = true; - }, - onerror: null, - }; - }, - onTile: (tile) => received.push(tile), - }); - - listener?.({ data: JSON.stringify({ state: "ready" }) } as MessageEvent); - expect(received).toEqual([{ state: "ready" }]); - unsubscribe(); - expect(closed).toBe(true); - }); - - test("falls back when dashboard tile events are unavailable or fail", () => { - let unavailableCount = 0; - - subscribeToDashboardTileEvents({ - createEventSource: undefined, - onTile: () => undefined, - onUnavailable: () => { - unavailableCount += 1; - }, - }); - - let errorHandler: (() => void) | null = null; - const unsubscribe = subscribeToDashboardTileEvents({ - createEventSource: () => ({ - addEventListener: () => undefined, - close: () => undefined, - get onerror() { - return errorHandler; - }, - set onerror(handler) { - errorHandler = handler; - }, - }), - onTile: () => undefined, - onUnavailable: () => { - unavailableCount += 1; - }, - }); - - if (!errorHandler) throw new Error("expected error handler"); - const triggerError = errorHandler as unknown as () => void; - triggerError(); - unsubscribe(); - expect(unavailableCount).toBe(2); - }); - - test("backs off failed tile keys and resets after success", () => { - const backoff = createDashboardTileBackoff(); - - backoff.recordFailure("telemetry:infra-ram", 1_000); - expect(backoff.canAttempt("telemetry:infra-ram", 15_999)).toBe(false); - expect(backoff.canAttempt("telemetry:infra-ram", 16_000)).toBe(true); - - backoff.recordFailure("telemetry:infra-ram", 16_000); - expect(backoff.canAttempt("telemetry:infra-ram", 45_999)).toBe(false); - expect(backoff.canAttempt("telemetry:infra-ram", 46_000)).toBe(true); - - backoff.recordFailure("telemetry:infra-ram", 46_000); - backoff.recordFailure("telemetry:infra-ram", 106_000); - expect(backoff.canAttempt("telemetry:infra-ram", 225_999)).toBe(false); - expect(backoff.canAttempt("telemetry:infra-ram", 226_000)).toBe(true); - - backoff.recordSuccess("telemetry:infra-ram"); - expect(backoff.canAttempt("telemetry:infra-ram", 107_000)).toBe(true); - }); - - test("stores only ready tile snapshots and restores them for matching revisions", () => { - const storage = createMemoryStorage(); - let now = 1_000; - const store = createDashboardTileSnapshotStore(storage, { - now: () => now, - }); - - store.saveReadyTile({ - currentRevisionId: "revision-a", - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live", - severity: "ok", - }, - }, - schemaVersion: "dashboard.v1", - }); - store.saveTile({ - currentRevisionId: "revision-a", - response: { - state: "not_found", - tile: { kind: "telemetry", id: "missing" }, - message: "Missing", - }, - schemaVersion: "dashboard.v1", - }); - store.saveTile({ - currentRevisionId: "revision-a", - response: { - state: "disabled", - tile: { kind: "module", id: "disabled-module" }, - message: "Disabled", - }, - schemaVersion: "dashboard.v1", - }); - - now = 61_000; - - const restored = store.restore({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - }); - - expect(restored).toEqual([ - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live - stale 60s", - severity: "stale", - }, - }, - }, - ]); - expect(store.restore({ - currentRevisionId: "revision-b", - schemaVersion: "dashboard.v1", - })).toEqual([]); - }); - - test("ignores malformed stored tile snapshots", () => { - const storage = createMemoryStorage(); - storage.setItem( - "dimensionlab.dashboard.tiles.v1", - JSON.stringify({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - tiles: [ - {}, - { - item: { - id: "incomplete", - detail: "live", - }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "incomplete" }, - }, - { - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live", - severity: "ok", - }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "infra-ram" }, - }, - { - item: { id: "broken-detail", detail: 42 }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "broken-detail" }, - }, - ], - version: 1, - }), - ); - - const store = createDashboardTileSnapshotStore(storage, { - now: () => 16_000, - }); - - expect(store.restore({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - })).toEqual([ - { - ageMs: 15_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live - stale 15s", - severity: "stale", - }, - }, - }, - ]); - }); - - test("ignores restored aggregate health snapshots", () => { - const storage = createMemoryStorage(); - storage.setItem( - "dimensionlab.dashboard.tiles.v1", - JSON.stringify({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - tiles: [ - { - item: { - id: "system-status", - label: "System Status", - value: "20 services down", - severity: "danger", - }, - savedAt: 1_000, - tile: { kind: "status", stripId: "footer-status", id: "system-status" }, - }, - { - item: { - id: "runtime-health-summary", - kind: "summary", - title: "Runtime Health", - value: "20 services down", - detail: "0 warnings - 8 services ok", - severity: "danger", - }, - savedAt: 1_000, - tile: { kind: "module", id: "runtime-health-summary" }, - }, - { - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live", - severity: "ok", - }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "infra-ram" }, - }, - ], - version: 1, - }), - ); - - const store = createDashboardTileSnapshotStore(storage, { - now: () => 16_000, - }); - - expect(store.restore({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - })).toEqual([ - { - ageMs: 15_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live - stale 15s", - severity: "stale", - }, - }, - }, - ]); - }); -}); - -async function waitFor(predicate: () => boolean) { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await Promise.resolve(); - } - - throw new Error("condition was not met"); -} - -function createMemoryStorage(): Storage { - const values = new Map(); - return { - get length() { - return values.size; - }, - clear() { - values.clear(); - }, - getItem(key) { - return values.get(key) ?? null; - }, - key(index) { - return [...values.keys()][index] ?? null; - }, - removeItem(key) { - values.delete(key); - }, - setItem(key, value) { - values.set(key, value); - }, - }; -} - -function modelElement(modelId: string) { - return { - getAttribute(name: string) { - return name === "data-model-id" ? modelId : null; - }, - }; -} diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts deleted file mode 100644 index 7c50359..0000000 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ /dev/null @@ -1,886 +0,0 @@ -export interface DashboardRefreshPauseState { - visibilityState: DocumentVisibilityState; - online: boolean; -} - -export type DashboardTileReference = - | { kind: "telemetry"; id: string } - | { kind: "service"; groupId: string; id: string } - | { kind: "module"; id: string } - | { kind: "status"; stripId: string; id: string }; - -type SnapshotMetricValue = Record; - -export type DashboardTileSnapshotItem = { - detail?: string; - id: string; - label?: string; - severity?: string; - value?: SnapshotMetricValue | string; -} & Record; - -export type DashboardTileSnapshotResponse = - | { - state: "ready"; - tile: DashboardTileReference; - item: DashboardTileSnapshotItem; - } - | { - state: "not_found"; - tile: DashboardTileReference; - message: string; - } - | { - state: "disabled"; - tile: DashboardTileReference; - message: string; - }; - -export function shouldPauseDashboardRefresh( - state: DashboardRefreshPauseState, -): boolean { - return state.visibilityState !== "visible" || !state.online; -} - -export function createDashboardRequestAborter() { - let shellController: AbortController | undefined; - let tileController: AbortController | undefined; - - function abortController(controller: AbortController | undefined) { - if (controller && !controller.signal.aborted) { - controller.abort(); - } - } - - return { - beginShellRun(): AbortSignal { - abortController(shellController); - abortController(tileController); - shellController = new AbortController(); - tileController = undefined; - return shellController.signal; - }, - beginTileRun(): AbortSignal { - abortController(tileController); - tileController = new AbortController(); - return tileController.signal; - }, - abortActiveRequests(): void { - abortController(shellController); - abortController(tileController); - shellController = undefined; - tileController = undefined; - }, - }; -} - -type DashboardRefreshEventTarget = Pick< - EventTarget, - "addEventListener" | "removeEventListener" ->; - -export interface DashboardRefreshLifecycleOptions { - documentTarget: DashboardRefreshEventTarget; - windowTarget: DashboardRefreshEventTarget; - loadDashboard: () => void | Promise; - pauseRefreshes: () => void; - refreshPaused: () => boolean; -} - -export function attachDashboardRefreshLifecycle( - options: DashboardRefreshLifecycleOptions, -): () => void { - function handleRefreshLifecycleChange() { - if (options.refreshPaused()) { - options.pauseRefreshes(); - return; - } - - void options.loadDashboard(); - } - - function handlePageHide() { - options.pauseRefreshes(); - } - - options.documentTarget.addEventListener( - "visibilitychange", - handleRefreshLifecycleChange, - ); - options.windowTarget.addEventListener("online", handleRefreshLifecycleChange); - options.windowTarget.addEventListener("offline", handleRefreshLifecycleChange); - options.windowTarget.addEventListener("pagehide", handlePageHide); - - return () => { - options.documentTarget.removeEventListener( - "visibilitychange", - handleRefreshLifecycleChange, - ); - options.windowTarget.removeEventListener("online", handleRefreshLifecycleChange); - options.windowTarget.removeEventListener("offline", handleRefreshLifecycleChange); - options.windowTarget.removeEventListener("pagehide", handlePageHide); - }; -} - -export interface DashboardHydrationQueueOptions { - concurrency: number; - hydrate: (item: TItem) => Promise | void; - items: TItem[]; - signal: AbortSignal; -} - -export async function runDashboardHydrationQueue( - options: DashboardHydrationQueueOptions, -): Promise { - const concurrency = Math.max(1, Math.floor(options.concurrency)); - let nextIndex = 0; - - async function worker() { - while (!options.signal.aborted) { - const item = options.items[nextIndex]; - nextIndex += 1; - if (item === undefined) return; - - await options.hydrate(item); - } - } - - const workerCount = Math.min(concurrency, options.items.length); - await Promise.all( - Array.from({ length: workerCount }, () => worker()), - ); -} - -export interface DashboardViewportHydrationQueueOptions { - batchSize?: number; - collectVisibleModelIds: () => Promise>; - concurrency: number; - getModelId: (item: TItem) => string; - hydrate: (item: TItem) => Promise | void; - hydrateBatch?: (items: TItem[]) => Promise | void; - items: TItem[]; - onAllItemsSettled?: () => void; - onVisibleItemsSettled?: () => void; - signal: AbortSignal; - waitForIdle: () => Promise; -} - -export async function runViewportAwareDashboardHydrationQueue( - options: DashboardViewportHydrationQueueOptions, -): Promise { - const visibleModelIds = await options.collectVisibleModelIds(); - if (options.signal.aborted) return; - - const { visible, deferred } = splitDashboardHydrationItemsByVisibility({ - getModelId: options.getModelId, - items: options.items, - visibleModelIds, - }); - - await runDashboardHydrationItems(options, visible); - if (options.signal.aborted) return; - options.onVisibleItemsSettled?.(); - - if (!deferred.length) { - options.onAllItemsSettled?.(); - return; - } - - await options.waitForIdle(); - if (options.signal.aborted) return; - - await runDashboardHydrationItems(options, deferred); - options.onAllItemsSettled?.(); -} - -async function runDashboardHydrationItems( - options: DashboardViewportHydrationQueueOptions, - items: TItem[], -): Promise { - if (!options.hydrateBatch) { - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items, - signal: options.signal, - }); - return; - } - - await runDashboardHydrationBatchQueue({ - batchSize: options.batchSize ?? options.concurrency, - hydrateBatch: options.hydrateBatch, - items, - signal: options.signal, - }); -} - -export async function runDashboardHydrationBatchQueue(options: { - batchSize: number; - hydrateBatch: (items: TItem[]) => Promise | void; - items: TItem[]; - signal: AbortSignal; -}): Promise { - const batchSize = Math.max(1, Math.floor(options.batchSize)); - - for (let index = 0; index < options.items.length; index += batchSize) { - if (options.signal.aborted) return; - await options.hydrateBatch(options.items.slice(index, index + batchSize)); - } -} - -export function splitDashboardHydrationItemsByVisibility(options: { - getModelId: (item: TItem) => string; - items: TItem[]; - visibleModelIds: ReadonlySet; -}): { - deferred: TItem[]; - visible: TItem[]; -} { - const visible: TItem[] = []; - const deferred: TItem[] = []; - - for (const item of options.items) { - if (options.visibleModelIds.has(options.getModelId(item))) { - visible.push(item); - } else { - deferred.push(item); - } - } - - return { visible, deferred }; -} - -export interface DashboardModelElement { - getAttribute(name: string): string | null; -} - -export interface DashboardViewportElementSource { - querySelectorAll(selector: string): ArrayLike; -} - -export interface DashboardIntersectionEntry { - intersectionRatio?: number; - isIntersecting: boolean; - target: DashboardModelElement; -} - -export interface DashboardIntersectionObserver { - disconnect(): void; - observe(element: DashboardModelElement): void; -} - -export type DashboardIntersectionObserverFactory = ( - callback: (entries: DashboardIntersectionEntry[]) => void, -) => DashboardIntersectionObserver; - -type DashboardTimerHandle = ReturnType; - -export interface DashboardVisibleModelIdCollectorOptions { - clearTimeout?: (handle: DashboardTimerHandle) => void; - createObserver?: DashboardIntersectionObserverFactory; - documentTarget: DashboardViewportElementSource; - modelIds: Iterable; - setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; - signal: AbortSignal; - timeoutMs?: number; -} - -export async function collectVisibleDashboardModelIds( - options: DashboardVisibleModelIdCollectorOptions, -): Promise> { - const targetIds = new Set(options.modelIds); - if (!targetIds.size || options.signal.aborted) return new Set(); - - const elements = Array.from( - options.documentTarget.querySelectorAll("[data-model-id]"), - ).filter((element) => { - const modelId = element.getAttribute("data-model-id"); - return modelId ? targetIds.has(modelId) : false; - }); - - if (!elements.length) return new Set(); - if (!options.createObserver) return targetIds; - const createObserver = options.createObserver; - - const setTimer = - options.setTimeout || - ((callback: () => void, timeoutMs: number) => - globalThis.setTimeout(callback, timeoutMs)); - const clearTimer = - options.clearTimeout || - ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); - const timeoutMs = options.timeoutMs ?? 80; - - return new Promise((resolve) => { - const visibleModelIds = new Set(); - let settled = false; - let timeoutHandle: DashboardTimerHandle | undefined; - let observer: DashboardIntersectionObserver | undefined; - - function cleanup() { - if (timeoutHandle !== undefined) { - clearTimer(timeoutHandle); - } - observer?.disconnect(); - options.signal.removeEventListener("abort", finish); - } - - function finish() { - if (settled) return; - settled = true; - cleanup(); - resolve(visibleModelIds); - } - - observer = createObserver((entries) => { - for (const entry of entries) { - const modelId = entry.target.getAttribute("data-model-id"); - if ( - modelId && - targetIds.has(modelId) && - (entry.isIntersecting || (entry.intersectionRatio ?? 0) > 0) - ) { - visibleModelIds.add(modelId); - } - } - }); - - for (const element of elements) { - observer.observe(element); - } - - options.signal.addEventListener("abort", finish, { once: true }); - timeoutHandle = setTimer(finish, timeoutMs); - }); -} - -export interface DashboardHydrationIdleOptions { - cancelIdleCallback?: (handle: number) => void; - clearTimeout?: (handle: DashboardTimerHandle) => void; - requestIdleCallback?: ( - callback: () => void, - options?: { timeout?: number }, - ) => number; - setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; - signal: AbortSignal; - timeoutMs?: number; -} - -export async function waitForDashboardHydrationIdle( - options: DashboardHydrationIdleOptions, -): Promise { - if (options.signal.aborted) return; - - const setTimer = - options.setTimeout || - ((callback: () => void, timeoutMs: number) => - globalThis.setTimeout(callback, timeoutMs)); - const clearTimer = - options.clearTimeout || - ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); - - await new Promise((resolve) => { - let settled = false; - let idleHandle: number | undefined; - let timeoutHandle: DashboardTimerHandle | undefined; - - function cleanup() { - if (idleHandle !== undefined) { - options.cancelIdleCallback?.(idleHandle); - } - if (timeoutHandle !== undefined) { - clearTimer(timeoutHandle); - } - options.signal.removeEventListener("abort", finish); - } - - function finish() { - if (settled) return; - settled = true; - cleanup(); - resolve(); - } - - options.signal.addEventListener("abort", finish, { once: true }); - if (options.requestIdleCallback) { - idleHandle = options.requestIdleCallback(finish, { - timeout: options.timeoutMs ?? 1_000, - }); - } else { - timeoutHandle = setTimer(finish, 0); - } - }); -} - -export interface DashboardRefreshDelayOptions { - failureMultiplierLimit?: number; - jitterRatio?: number; - random?: () => number; -} - -export function createDashboardRefreshDelay( - options: DashboardRefreshDelayOptions = {}, -) { - const random = options.random || Math.random; - const jitterRatio = options.jitterRatio ?? 0.1; - const failureMultiplierLimit = options.failureMultiplierLimit ?? 8; - let consecutiveFailures = 0; - - return { - nextDelayMs(baseDelayMs: number): number { - const failureMultiplier = consecutiveFailures - ? Math.min(2 ** consecutiveFailures, failureMultiplierLimit) - : 1; - const jitterFactor = 1 + ((random() * 2) - 1) * jitterRatio; - return Math.max(0, Math.round(baseDelayMs * failureMultiplier * jitterFactor)); - }, - recordFailure(): void { - consecutiveFailures += 1; - }, - recordSuccess(): void { - consecutiveFailures = 0; - }, - }; -} - -export interface DashboardPerformanceMarkOptions { - mark?: (name: string) => void; -} - -export const dashboardPerformanceMarks = { - allTilesSettled: "dashboard:all-tiles-settled", - firstTileReady: "dashboard:first-tile-ready", - shellLoad: "dashboard:shell-load", - visibleTilesReady: "dashboard:visible-tiles-ready", -} as const; - -export function createDashboardPerformanceMarks( - options: DashboardPerformanceMarkOptions = {}, -) { - const mark = options.mark || - globalThis.performance?.mark?.bind(globalThis.performance); - let firstTileReadyMarked = false; - - function safeMark(name: string) { - try { - mark?.(name); - } catch { - // Performance marks are diagnostics only. - } - } - - return { - markAllTilesSettled(): void { - safeMark(dashboardPerformanceMarks.allTilesSettled); - }, - markFirstTileReady(): void { - if (firstTileReadyMarked) return; - firstTileReadyMarked = true; - safeMark(dashboardPerformanceMarks.firstTileReady); - }, - markShellLoad(): void { - firstTileReadyMarked = false; - safeMark(dashboardPerformanceMarks.shellLoad); - }, - markVisibleTilesReady(): void { - safeMark(dashboardPerformanceMarks.visibleTilesReady); - }, - }; -} - -export interface DashboardTileEventSource { - addEventListener( - type: "dashboard-tile", - listener: (event: MessageEvent) => void, - ): void; - close(): void; - onerror: (() => void) | null; -} - -export interface DashboardTileEventSubscriptionOptions { - createEventSource?: (url: string) => DashboardTileEventSource; - onTile: (data: unknown) => void; - onUnavailable?: () => void; - url?: string; -} - -export function subscribeToDashboardTileEvents( - options: DashboardTileEventSubscriptionOptions, -): () => void { - const createEventSource = options.createEventSource || - (typeof globalThis.EventSource !== "undefined" - ? (url: string) => new globalThis.EventSource(url) - : undefined); - - if (!createEventSource) { - options.onUnavailable?.(); - return () => undefined; - } - - const source = createEventSource(options.url || "/api/dashboard/events"); - source.addEventListener("dashboard-tile", (event) => { - try { - options.onTile(JSON.parse(event.data)); - } catch { - // Ignore malformed diagnostics from an optional live transport. - } - }); - source.onerror = () => { - source.close(); - options.onUnavailable?.(); - }; - - return () => source.close(); -} - -const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000]; - -export function createDashboardTileBackoff() { - const failures = new Map(); - - return { - canAttempt(key: string, now = Date.now()): boolean { - const failure = failures.get(key); - return !failure || now >= failure.nextAttemptAt; - }, - recordFailure(key: string, now = Date.now()): void { - const previousAttempts = failures.get(key)?.attempts || 0; - const attempts = previousAttempts + 1; - const delay = - dashboardTileBackoffDelaysMs[ - Math.min(attempts - 1, dashboardTileBackoffDelaysMs.length - 1) - ]; - failures.set(key, { - attempts, - nextAttemptAt: now + delay, - }); - }, - recordSuccess(key: string): void { - failures.delete(key); - }, - }; -} - -interface DashboardTileSnapshotRecord { - item: DashboardTileSnapshotItem; - savedAt: number; - tile: DashboardTileReference; -} - -interface DashboardTileSnapshotPayload { - currentRevisionId: string; - schemaVersion: string; - tiles: DashboardTileSnapshotRecord[]; - version: 1; -} - -export interface DashboardTileSnapshotStoreContext { - currentRevisionId: string; - schemaVersion: string; -} - -export interface DashboardTileSnapshotStoreOptions { - now?: () => number; -} - -export interface RestoredDashboardTileSnapshot { - ageMs: number; - response: Extract; -} - -const dashboardTileSnapshotStorageKey = "dimensionlab.dashboard.tiles.v1"; - -export function createDashboardTileSnapshotStore( - storage: Storage | undefined, - options: DashboardTileSnapshotStoreOptions = {}, -) { - const now = options.now || Date.now; - - function read(): DashboardTileSnapshotPayload | null { - if (!storage) return null; - - try { - const serialized = storage.getItem(dashboardTileSnapshotStorageKey); - if (!serialized) return null; - - const payload = JSON.parse(serialized) as Partial; - if ( - payload.version !== 1 || - typeof payload.currentRevisionId !== "string" || - typeof payload.schemaVersion !== "string" || - !Array.isArray(payload.tiles) - ) { - return null; - } - - return { - currentRevisionId: payload.currentRevisionId, - schemaVersion: payload.schemaVersion, - tiles: payload.tiles - .filter(isDashboardTileSnapshotRecord) - .filter((record) => isPersistableDashboardTileSnapshot(record.tile)), - version: 1, - }; - } catch { - return null; - } - } - - function write(payload: DashboardTileSnapshotPayload): void { - if (!storage) return; - - try { - storage.setItem(dashboardTileSnapshotStorageKey, JSON.stringify(payload)); - } catch { - // Best-effort warm-start cache; quota and privacy failures are non-fatal. - } - } - - function matchingPayload( - context: DashboardTileSnapshotStoreContext, - ): DashboardTileSnapshotPayload { - const payload = read(); - if ( - payload && - payload.currentRevisionId === context.currentRevisionId && - payload.schemaVersion === context.schemaVersion - ) { - return payload; - } - - return { - currentRevisionId: context.currentRevisionId, - schemaVersion: context.schemaVersion, - tiles: [], - version: 1, - }; - } - - function saveReadyTile( - input: DashboardTileSnapshotStoreContext & { - response: Extract; - }, - ): void { - if (!isPersistableDashboardTileSnapshot(input.response.tile)) return; - - const payload = matchingPayload(input); - const key = dashboardTileSnapshotKey(input.response.tile); - const nextRecord: DashboardTileSnapshotRecord = { - item: input.response.item, - savedAt: now(), - tile: input.response.tile, - }; - payload.tiles = [ - nextRecord, - ...payload.tiles.filter((record) => - dashboardTileSnapshotKey(record.tile) !== key - ), - ]; - write(payload); - } - - return { - restore(context: DashboardTileSnapshotStoreContext): RestoredDashboardTileSnapshot[] { - const payload = read(); - if ( - !payload || - payload.currentRevisionId !== context.currentRevisionId || - payload.schemaVersion !== context.schemaVersion - ) { - return []; - } - - const restoredAt = now(); - return payload.tiles.map((record) => ({ - ageMs: Math.max(0, restoredAt - record.savedAt), - response: { - state: "ready", - tile: record.tile, - item: { - ...record.item, - detail: staleDashboardTileDetail(record.item.detail, restoredAt - record.savedAt), - severity: "stale", - }, - }, - })); - }, - saveReadyTile, - saveTile(input: DashboardTileSnapshotStoreContext & { - response: DashboardTileSnapshotResponse; - }): void { - if (input.response.state === "ready") { - saveReadyTile({ - currentRevisionId: input.currentRevisionId, - response: input.response, - schemaVersion: input.schemaVersion, - }); - } - }, - }; -} - -function dashboardTileSnapshotKey(tile: DashboardTileReference): string { - return JSON.stringify(tile); -} - -export function isPersistableDashboardTileSnapshot( - tile: DashboardTileReference, -): boolean { - if (tile.kind === "status" && tile.id === "system-status") return false; - if (tile.kind === "module" && tile.id === "runtime-health-summary") return false; - return true; -} - -function isDashboardTileSnapshotRecord( - value: unknown, -): value is DashboardTileSnapshotRecord { - if (!isSnapshotRecord(value)) return false; - - return ( - typeof value.savedAt === "number" && - Number.isFinite(value.savedAt) && - isDashboardTileReference(value.tile) && - isDashboardTileSnapshotItem(value.tile, value.item) - ); -} - -function isDashboardTileReference( - value: unknown, -): value is DashboardTileReference { - if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false; - - switch (value.kind) { - case "telemetry": - case "module": - return typeof value.id === "string"; - case "service": - return typeof value.groupId === "string" && typeof value.id === "string"; - case "status": - return typeof value.stripId === "string" && typeof value.id === "string"; - default: - return false; - } -} - -function isDashboardTileSnapshotItem( - tile: DashboardTileReference, - value: unknown, -): value is DashboardTileSnapshotItem { - if (!isSnapshotRecord(value) || value.id !== tile.id) return false; - - switch (tile.kind) { - case "telemetry": - return ( - typeof value.label === "string" && - isMetricValue(value.value) && - isSeverity(value.severity) && - isOptionalString(value.detail) && - isOptionalString(value.description) && - isOptionalString(value.icon) && - isOptionalNumberArray(value.sparkline) - ); - case "service": - return ( - typeof value.label === "string" && - typeof value.description === "string" && - isSeverity(value.severity) && - isOptionalString(value.detail) && - isOptionalString(value.icon) && - isOptionalLink(value.link) - ); - case "module": - return ( - (value.kind === "summary" || - value.kind === "weather" || - value.kind === "custom") && - isOptionalString(value.title) && - isOptionalString(value.label) && - isOptionalString(value.value) && - isOptionalString(value.detail) && - isOptionalString(value.icon) && - (value.severity === undefined || isSeverity(value.severity)) - ); - case "status": - return ( - typeof value.label === "string" && - typeof value.value === "string" && - isOptionalLink(value.link) && - (value.severity === undefined || isSeverity(value.severity)) - ); - } -} - -function isSnapshotRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isMetricValue(value: unknown): boolean { - if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false; - - if (value.kind === "text") { - return ( - typeof value.value === "string" && - isOptionalString(value.unit) - ); - } - - const numericKinds = ["bytes", "latency", "number", "percent", "temperature"]; - if (!numericKinds.includes(value.kind)) return false; - if (typeof value.value !== "number" || !Number.isFinite(value.value)) return false; - if (value.kind === "percent" && (value.value < 0 || value.value > 100)) { - return false; - } - - const precision = value.precision; - return ( - isOptionalString(value.unit) && - (precision === undefined || - (typeof precision === "number" && - Number.isInteger(precision) && - precision >= 0 && - precision <= 4)) - ); -} - -function isSeverity(value: unknown): boolean { - return ( - value === "neutral" || - value === "ok" || - value === "warning" || - value === "danger" || - value === "stale" || - value === "unavailable" - ); -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === "string"; -} - -function isOptionalNumberArray(value: unknown): boolean { - return ( - value === undefined || - (Array.isArray(value) && - value.every((item) => typeof item === "number" && Number.isFinite(item))) - ); -} - -function isOptionalLink(value: unknown): boolean { - return ( - value === undefined || - (isSnapshotRecord(value) && - typeof value.href === "string" && - isOptionalString(value.label) && - (value.external === undefined || typeof value.external === "boolean")) - ); -} - -function staleDashboardTileDetail( - detail: string | undefined, - ageMs: number, -): string | undefined { - if (!detail) return detail; - const ageSeconds = Math.max(0, Math.floor(ageMs / 1_000)); - return ageSeconds > 0 ? `${detail} - stale ${ageSeconds}s` : detail; -} diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts index 1a97c3b..31c01d9 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts @@ -128,6 +128,7 @@ const verifiedSeedIconIds = new Set([ "mdi:pulse", "mdi:robot-outline", "mdi:router-network", + "mdi:text-box-search", "mdi:thermometer", "mdi:web", "mdi:weather-sunny", diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.ts index 59162e6..6cc544b 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.ts @@ -431,6 +431,14 @@ export const dimensionLabDashboardFixture: DashboardDocument = { href: "https://models.dimensionlab.net", datasource: uptimeMonitor(7), }), + service({ + id: "prompt-registry", + label: "Prompt Registry", + description: "Shared prompts, traces, evals", + icon: "mdi:text-box-search", + href: "https://prompts.dimensionlab.net", + datasource: uptimeMonitor(20), + }), ]), group("systems", "Systems", [ service({ diff --git a/apps/web/src/lib/server/dashboard.test.ts b/apps/web/src/lib/server/dashboard.test.ts index 5d93422..7851ebb 100644 --- a/apps/web/src/lib/server/dashboard.test.ts +++ b/apps/web/src/lib/server/dashboard.test.ts @@ -66,6 +66,9 @@ describe("dashboard runtime loader", () => { expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain( "auto-refresh", ); + expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain( + "prompt-registry", + ); expect(store.listRevisions()).toHaveLength(2); expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed"); }); @@ -135,5 +138,13 @@ function olderDimensionLabSeed() { ...strip, items: strip.items.filter((item) => item.id !== "auto-refresh"), })); + document.serviceGroups = document.serviceGroups.map((group) => + group.id === "ai-automation" + ? { + ...group, + services: group.services.filter((service) => service.id !== "prompt-registry"), + } + : group, + ); return document; } diff --git a/apps/web/src/lib/server/dashboard.ts b/apps/web/src/lib/server/dashboard.ts index 33afa3a..47ebfa2 100644 --- a/apps/web/src/lib/server/dashboard.ts +++ b/apps/web/src/lib/server/dashboard.ts @@ -18,9 +18,6 @@ export interface DashboardRuntimeReady { document: DashboardDocument; schemaVersion: string; currentRevisionId: string; - liveDatasourceHydration?: { - enabled: boolean; - }; } export interface DashboardRuntimeEmpty { diff --git a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts index d24b48b..61acdd3 100644 --- a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts +++ b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts @@ -3,7 +3,7 @@ import { DASHBOARD_SCHEMA_VERSION, type DashboardDocument, } from "@dimensionlab/dashboard-model"; -import { resolveDashboardDatasources, resolveDashboardTile } from "."; +import { resolveDashboardDatasources } from "."; describe("dashboard datasource resolution", () => { test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => { @@ -124,100 +124,6 @@ describe("dashboard datasource resolution", () => { expect(resolved).not.toBe(testDocument); expect(testDocument.telemetry[0].value.value).toBe(1); }); - - test("shares service health snapshots across aggregate tile hydration", async () => { - let resolveFetch: ((response: Response) => void) | undefined; - const fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url !== "https://service.example/health") { - throw new Error(`Unhandled test request: ${url}`); - } - - return new Promise((resolve) => { - resolveFetch = resolve; - }); - }); - const document = testDashboard(); - - const moduleTile = resolveDashboardTile( - document, - { kind: "module", id: "runtime-health-summary" }, - { fetch }, - ); - const statusTile = resolveDashboardTile( - document, - { kind: "status", stripId: "footer", id: "system-status" }, - { fetch }, - ); - - await Promise.resolve(); - expect(fetch).toHaveBeenCalledTimes(1); - - resolveFetch?.(jsonResponse({ status: "UP", ping: 42 })); - expect(await moduleTile).toMatchObject({ - state: "ready", - item: { - id: "runtime-health-summary", - severity: "ok", - value: "all systems operational", - }, - }); - expect(await statusTile).toMatchObject({ - state: "ready", - item: { - id: "system-status", - severity: "ok", - value: "All systems operational", - }, - }); - }); - - test("isolates service health snapshots by datasource fetch context", async () => { - const firstFetch = vi.fn(async () => - jsonResponse({ - status: "UP", - ping: 42, - }) - ); - const secondFetch = vi.fn(async () => - jsonResponse({ - status: "DOWN", - ping: 0, - }) - ); - const document = testDashboard(); - document.serviceGroups[0].services[0] = { - ...document.serviceGroups[0].services[0], - id: "api-isolated", - datasource: { - type: "external", - adapter: "http-status", - reference: "GET https://service.example/isolated", - }, - }; - - await resolveDashboardTile( - document, - { kind: "status", stripId: "footer", id: "system-status" }, - { fetch: firstFetch }, - ); - const second = await resolveDashboardTile( - document, - { kind: "status", stripId: "footer", id: "system-status" }, - { fetch: secondFetch }, - ); - - expect(firstFetch).toHaveBeenCalledTimes(1); - expect(secondFetch).toHaveBeenCalledTimes(1); - expect(second).toMatchObject({ - state: "ready", - item: { - id: "system-status", - severity: "danger", - value: "1 service down", - }, - }); - }); }); const testDocument = testDashboard(); diff --git a/apps/web/src/lib/server/datasources/index.ts b/apps/web/src/lib/server/datasources/index.ts index 62df13a..d69db86 100644 --- a/apps/web/src/lib/server/datasources/index.ts +++ b/apps/web/src/lib/server/datasources/index.ts @@ -10,38 +10,8 @@ import type { TelemetryCard, } from "@dimensionlab/dashboard-model"; -export type DashboardTileReference = - | { kind: "telemetry"; id: string } - | { kind: "service"; groupId: string; id: string } - | { kind: "module"; id: string } - | { kind: "status"; stripId: string; id: string }; - -export type DashboardTileItem = - | DashboardModule - | ServiceEntry - | StatusItem - | TelemetryCard; - -export type DashboardTileResolution = - | { - state: "ready"; - tile: DashboardTileReference; - item: DashboardTileItem; - } - | { - state: "not_found"; - tile: DashboardTileReference; - message: string; - } - | { - state: "disabled"; - tile: DashboardTileReference; - message: string; - }; - export interface DatasourceResolutionOptions { fetch?: DatasourceFetch; - now?: () => number; prometheusBaseUrl?: string; prometheusRangeSeconds?: number; prometheusStepSeconds?: number; @@ -76,90 +46,16 @@ export async function resolveDashboardDatasources( }; } -export async function resolveDashboardTile( - document: DashboardDocument, - tile: DashboardTileReference, - options: DatasourceResolutionOptions = {}, -): Promise { - const context = datasourceContext(options); - - if (tile.kind === "telemetry") { - const card = document.telemetry.find((item) => item.id === tile.id); - if (!card) return missingTile(tile); - - return { - state: "ready", - tile, - item: await resolveTelemetryCard(card, context), - }; - } - - if (tile.kind === "service") { - const service = document.serviceGroups - .find((group) => group.id === tile.groupId) - ?.services.find((item) => item.id === tile.id); - if (!service) return missingTile(tile); - - return { - state: "ready", - tile, - item: await resolveService(service, context), - }; - } - - if (tile.kind === "module") { - const module = document.modules?.find((item) => item.id === tile.id); - if (!module) return missingTile(tile); - - const item = module.id === "runtime-health-summary" - ? runtimeHealthSummary( - module, - await serviceGroupsSnapshot(document, context), - ) - : await resolveModule(module, context); - - return { state: "ready", tile, item }; - } - - const strip = document.statusStrips.find((item) => item.id === tile.stripId); - const statusItem = strip?.items.find((item) => item.id === tile.id); - if (!strip || !statusItem) return missingTile(tile); - - return { - state: "ready", - tile, - item: await resolveStatusTile( - statusItem, - document.metadata.refreshIntervalSeconds, - document, - context, - ), - }; -} - interface DatasourceContext { fetch: DatasourceFetch; - fetchIdentity: number; - now: () => number; prometheusBaseUrl: string; prometheusRangeSeconds: number; prometheusStepSeconds: number; requestTimeoutMs: number; - serviceGroupsSnapshot?: Promise; } type DatasourceFetch = (input: string, init?: RequestInit) => Promise; -interface ServiceGroupsSnapshotEntry { - expiresAt: number; - snapshot: Promise; -} - -const serviceGroupsSnapshotTtlMs = 30_000; -const serviceGroupsSnapshotCache = new Map(); -const datasourceFetchIdentities = new WeakMap(); -let nextDatasourceFetchIdentity = 1; - interface PrometheusVectorResult { metric?: Record; value?: [number, string]; @@ -171,12 +67,8 @@ interface PrometheusMatrixResult { } function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext { - const fetch = options.fetch || globalThis.fetch; - return { - fetch, - fetchIdentity: datasourceFetchIdentity(fetch), - now: options.now || Date.now, + fetch: options.fetch || globalThis.fetch, prometheusBaseUrl: options.prometheusBaseUrl || process.env.PROMETHEUS_BASE_URL || @@ -187,68 +79,6 @@ function datasourceContext(options: DatasourceResolutionOptions): DatasourceCont }; } -function datasourceFetchIdentity(fetch: DatasourceFetch): number { - const existing = datasourceFetchIdentities.get(fetch); - if (existing) return existing; - - const next = nextDatasourceFetchIdentity; - nextDatasourceFetchIdentity += 1; - datasourceFetchIdentities.set(fetch, next); - return next; -} - -function serviceGroupsSnapshot( - document: DashboardDocument, - context: DatasourceContext, -): Promise { - if (context.serviceGroupsSnapshot) return context.serviceGroupsSnapshot; - - const key = serviceGroupsSnapshotKey(document, context); - const now = context.now(); - const cached = serviceGroupsSnapshotCache.get(key); - if (cached && cached.expiresAt > now) { - context.serviceGroupsSnapshot = cached.snapshot; - return cached.snapshot; - } - - const snapshot = Promise.all( - document.serviceGroups.map((group) => resolveServiceGroup(group, context)), - ); - context.serviceGroupsSnapshot = snapshot; - serviceGroupsSnapshotCache.set(key, { - expiresAt: now + serviceGroupsSnapshotTtlMs, - snapshot, - }); - snapshot.catch(() => { - if (serviceGroupsSnapshotCache.get(key)?.snapshot === snapshot) { - serviceGroupsSnapshotCache.delete(key); - } - }); - return snapshot; -} - -function serviceGroupsSnapshotKey( - document: DashboardDocument, - context: DatasourceContext, -): string { - return JSON.stringify( - { - fetchIdentity: context.fetchIdentity, - prometheusBaseUrl: context.prometheusBaseUrl, - prometheusRangeSeconds: context.prometheusRangeSeconds, - prometheusStepSeconds: context.prometheusStepSeconds, - requestTimeoutMs: context.requestTimeoutMs, - serviceGroups: document.serviceGroups.map((group) => ({ - id: group.id, - services: group.services.map((service) => ({ - datasource: service.datasource, - id: service.id, - })), - })), - }, - ); -} - async function resolveTelemetryCard( card: TelemetryCard, context: DatasourceContext, @@ -560,66 +390,6 @@ function resolveStatusItem( return structuredClone(item); } -async function resolveStatusTile( - item: StatusItem, - refreshIntervalSeconds: number | undefined, - document: DashboardDocument, - context: DatasourceContext, -): Promise { - if (item.id === "system-status") { - const resolvedGroups = await serviceGroupsSnapshot(document, context); - const health = serviceHealthSummary(resolvedGroups); - return { - ...structuredClone(item), - value: health.value, - severity: health.severity, - }; - } - - if (item.id === "last-sync") { - return { - ...structuredClone(item), - value: "just now", - severity: "ok", - }; - } - - if (item.id === "uptime") { - const uptime = await prometheusScalar( - 'time() - node_boot_time_seconds{job="node",host="linux-infra"}', - context, - ).catch(() => null); - return uptime === null - ? structuredClone(item) - : { - ...structuredClone(item), - value: formatDuration(uptime), - severity: "ok", - }; - } - - if (item.id === "load-avg") { - const loadAverage = await prometheusLoadAverage(context).catch(() => null); - return loadAverage - ? { - ...structuredClone(item), - value: loadAverage, - severity: "neutral", - } - : structuredClone(item); - } - - if (item.id === "auto-refresh" && refreshIntervalSeconds) { - return { - ...structuredClone(item), - value: `${refreshIntervalSeconds}s`, - severity: "neutral", - }; - } - - return structuredClone(item); -} - function serviceHealthSummary(serviceGroups: ServiceGroup[]): { severity: Severity; value: string; @@ -903,17 +673,3 @@ function formatDuration(totalSeconds: number): string { const minutes = Math.floor((seconds % 3_600) / 60); return `${days}d ${hours}h ${minutes}m`; } - -function missingTile(tile: DashboardTileReference): DashboardTileResolution { - return { - state: "not_found", - tile, - message: `Dashboard tile not found: ${tileKey(tile)}`, - }; -} - -function tileKey(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`; - if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`; - return `${tile.kind}:${tile.id}`; -} diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index 58cf203..38a3f1b 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -1,6 +1,4 @@ -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -44,8 +42,6 @@ describe("workspace boundaries", () => { "turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e", ); expect(webPackage.scripts).not.toHaveProperty("test:qa"); - expect(webPackage.scripts?.test).toBe("bun --bun vitest run"); - expect(webPackage.scripts?.["test:unit"]).toBe("bun --bun vitest run"); expect(turboConfig.tasks).not.toHaveProperty("test:qa"); expect(turboConfig.globalDependencies).toEqual( expect.arrayContaining(["bun.lock", "tsconfig.base.json"]), @@ -199,7 +195,7 @@ describe("workspace boundaries", () => { const sourceCopy = "COPY --from=pruner /repo/out/full/ ./"; const tsconfigCopy = "COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./"; - const installCommand = "RUN bun install --frozen-lockfile --ignore-scripts"; + const installCommand = "RUN bun install --frozen-lockfile"; expect(containerfile).toContain("AS pruner"); expect(containerfile).toContain(pruneCommand); @@ -257,186 +253,6 @@ describe("workspace boundaries", () => { "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", ); }); - - test("defines Forgejo CI and main-branch deploy automation", () => { - const workflow = readFileSync( - join(root, ".forgejo/workflows/dimensionlab-website.yml"), - "utf8", - ); - - expect(workflow).toContain("name: Dimension Lab website"); - expect(workflow).toContain("pull_request:"); - expect(workflow).toContain("push:"); - expect(workflow).toContain("branches:"); - expect(workflow).toContain("- main"); - expect(workflow).toContain("runs-on: docker"); - expect(workflow).toContain("bun install --frozen-lockfile"); - expect(workflow).toContain("bun run check"); - expect(workflow).toContain("bun run test"); - expect(workflow).toContain("bun run build"); - expect(workflow).toContain("needs: ci"); - expect(workflow).toContain("runs-on: deploy"); - expect(workflow).toContain("github.event_name == 'push'"); - expect(workflow).toContain("github.ref == 'refs/heads/main'"); - expect(workflow).toContain( - "git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git", - ); - expect(workflow).toContain('git fetch --force --prune --depth=1 origin "$GITHUB_SHA"'); - expect(workflow).toContain("podman inspect dimensionlab-website"); - expect(workflow).toContain("DEPLOY_CONTAINER_CLI: podman"); - expect(workflow).toContain("PODMAN_SYSTEMD_UNIT"); - expect(workflow).toContain("scripts/deploy-dimensionlab-website.sh"); - }); - - test("keeps production deployment behind a guarded script", () => { - const deployScript = readFileSync( - join(root, "scripts/deploy-dimensionlab-website.sh"), - "utf8", - ); - - expect(deployScript).toContain("refs/heads/main"); - expect(deployScript).toContain("dimensionlab-website.service"); - expect(deployScript).toContain("localhost/dimensionlab-website"); - expect(deployScript).toContain("apps/web/Containerfile"); - expect(deployScript).toContain("rollback-"); - expect(deployScript).toContain("https://dimensionlab.net"); - expect(deployScript).toContain("/api/dashboard/tiles"); - expect(deployScript).toContain("--dry-run"); - expect(deployScript).toContain("DEPLOY_RESTART_STRATEGY"); - }); - - test("rolls back the latest image when production smoke checks fail", () => { - const result = runDeployScriptWithFakes( - { - curl: failingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("dimensionlab-website.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "quadlet-container", - DEPLOY_SHA: "1234567890abcdef", - DEPLOY_SMOKE_TIMEOUT_SECONDS: "0", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("smoke checks failed"); - expect(result.stderr).toContain("rolling back to localhost/dimensionlab-website:rollback-"); - expect(result.log).toMatch( - /tag localhost\/dimensionlab-website:latest localhost\/dimensionlab-website:rollback-\d{14}/, - ); - expect(result.log).toMatch( - /tag localhost\/dimensionlab-website:rollback-\d{14} localhost\/dimensionlab-website:latest/, - ); - expect(result.log.match(/^stop dimensionlab-website$/gm)).toHaveLength(2); - }); - - test("rolls back the latest image when the container fails to restart", () => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("dimensionlab-website.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_CONTAINER_START_TIMEOUT_SECONDS: "1", - DEPLOY_TEST_CONTAINER_IMAGE: "wrong", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "quadlet-container", - DEPLOY_SHA: "1234567890abcdef", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("did not restart on localhost/dimensionlab-website:latest"); - expect(result.stderr).toContain("rolling back to localhost/dimensionlab-website:rollback-"); - expect(result.stderr).toContain("rollback image is running"); - expect(result.log).toMatch( - /tag localhost\/dimensionlab-website:rollback-\d{14} localhost\/dimensionlab-website:latest/, - ); - expect(result.log.match(/^stop dimensionlab-website$/gm)).toHaveLength(2); - }); - - test.each([ - { - env: { DEPLOY_EVENT_NAME: "pull_request", DEPLOY_REF: "refs/heads/main" }, - message: "production deploys only run for push", - }, - { - env: { DEPLOY_EVENT_NAME: "push", DEPLOY_REF: "refs/heads/codex/test" }, - message: "expected refs/heads/main", - }, - ])("refuses guarded deploy contexts before host mutations", ({ env, message }) => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("dimensionlab-website.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_SHA: "1234567890abcdef", - ...env, - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain(message); - expect(result.log).toBe(""); - }); - - test("refuses stop-based deploys unless the container belongs to the expected unit", () => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("other.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "quadlet-container", - DEPLOY_SHA: "1234567890abcdef", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "refusing to stop dimensionlab-website; expected PODMAN_SYSTEMD_UNIT=dimensionlab-website.service", - ); - expect(result.log).not.toContain("build "); - expect(result.log).not.toContain("stop dimensionlab-website"); - }); - - test("checks the expected unit before auto falls back to stopping the container", () => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("other.service"), - systemctl: fakeSystemctlCommand({ active: false, show: true }), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "auto", - DEPLOY_SHA: "1234567890abcdef", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "refusing to stop dimensionlab-website; expected PODMAN_SYSTEMD_UNIT=dimensionlab-website.service", - ); - expect(result.log).not.toContain("stop dimensionlab-website"); - }); }); type WorkspacePackageExport = @@ -453,126 +269,3 @@ function expectPackageExport( ): void { expect(actual).toMatchObject(expected); } - -function runDeployScriptWithFakes( - commands: Record, - env: Record, -): { log: string; status: number | null; stderr: string; stdout: string } { - const tempDir = mkdtempSync(join(tmpdir(), "dimensionlab-deploy-test-")); - const logPath = join(tempDir, "commands.log"); - - for (const [name, source] of Object.entries(commands)) { - const commandPath = join(tempDir, name); - writeFileSync(commandPath, source); - chmodSync(commandPath, 0o755); - } - - const result = spawnSync("bash", [join(root, "scripts/deploy-dimensionlab-website.sh")], { - cwd: root, - encoding: "utf8", - env: { - ...process.env, - ...env, - DEPLOY_TEST_LOG: logPath, - PATH: `${tempDir}:${process.env.PATH ?? ""}`, - }, - }); - - return { - log: existsSync(logPath) ? readFileSync(logPath, "utf8") : "", - status: result.status, - stderr: result.stderr, - stdout: result.stdout, - }; -} - -const fakeGitCommand = `#!/usr/bin/env bash -case "$1" in - branch) - echo main - ;; - rev-parse) - echo 1234567890abcdef - ;; - config|submodule) - exit 0 - ;; -esac -`; - -function fakePodmanCommand(systemdUnit: string): string { - return `#!/usr/bin/env bash -state_file="$DEPLOY_TEST_LOG.state" -[ -f "$state_file" ] || printf 'initial' > "$state_file" -printf '%s\\n' "$*" >> "$DEPLOY_TEST_LOG" -if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then - if [ "$4" = "--format" ]; then - case "$3" in - *:rollback-*) - echo sha256:old - ;; - *) - if [ "$(cat "$state_file")" = "rollback" ]; then - echo sha256:old - else - echo sha256:new - fi - ;; - esac - fi - exit 0 -fi -if [ "$1" = "tag" ] && [ "$2" != "localhost/dimensionlab-website:latest" ]; then - printf 'rollback' > "$state_file" -fi -if [ "$1" = "inspect" ]; then - case "$*" in - *PODMAN_SYSTEMD_UNIT*) - echo ${systemdUnit} - ;; - *State.Running*) - echo true - ;; - *'.Image'*|*'{{.Image}}'*) - if [ "$(cat "$state_file")" = "rollback" ]; then - echo sha256:old - elif [ "\${DEPLOY_TEST_CONTAINER_IMAGE:-new}" = "wrong" ]; then - echo sha256:wrong - else - echo sha256:new - fi - ;; - esac -fi -`; -} - -const failingCurlCommand = `#!/usr/bin/env bash -printf 'curl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" -exit 22 -`; - -const passingCurlCommand = `#!/usr/bin/env bash -printf 'curl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" -if [ "$*" = *'/api/dashboard/tiles'* ]; then - printf '{"state":"ready","tiles":[]}' -fi -`; - -function fakeSystemctlCommand(options: { active: boolean; show: boolean }): string { - const activeStatus = options.active ? 0 : 3; - const showStatus = options.show ? 0 : 1; - - return `#!/usr/bin/env bash -printf 'systemctl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" -if [ "$1" = "--user" ] && [ "$2" = "is-active" ]; then - exit ${activeStatus} -fi -if [ "$1" = "--user" ] && [ "$2" = "show" ]; then - exit ${showStatus} -fi -if [ "$1" = "--user" ] && [ "$2" = "restart" ]; then - exit 0 -fi -`; -} diff --git a/apps/web/src/server/index.test.ts b/apps/web/src/server/index.test.ts deleted file mode 100644 index af1035d..0000000 --- a/apps/web/src/server/index.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterAll, describe, expect, test, vi } from "vitest"; -import { handleRequest } from "./index"; - -describe("server request routing", () => { - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); - - afterAll(() => { - consoleInfo.mockRestore(); - }); - - test("routes dashboard tile batch requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/tiles", { - method: "POST", - body: JSON.stringify({ - tiles: [ - { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - ], - }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - state: "ready", - tiles: [ - { - state: "ready", - tile: { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - }, - ], - }); - }); - - test("rejects non-post dashboard tile batch requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/tiles", { - method: "GET", - }), - ); - - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("POST"); - }); - - test("routes dashboard event stream requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/events", { - method: "GET", - }), - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "text/event-stream; charset=utf-8", - ); - }); - - test("rejects non-get dashboard event stream requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/events", { - method: "POST", - }), - ); - - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("GET"); - }); -}); diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts index 9cbcbd9..860d042 100644 --- a/apps/web/src/server/index.ts +++ b/apps/web/src/server/index.ts @@ -1,11 +1,6 @@ import { extname, normalize } from "node:path"; import { handleAgentDashboardRoute } from "./routes/agent-dashboard"; -import { - handleDashboardEventsRoute, - handleDashboardRoute, - handleDashboardTileRoute, - handleDashboardTilesRoute, -} from "./routes/dashboard"; +import { handleDashboardRoute } from "./routes/dashboard"; const host = process.env.HOST || "0.0.0.0"; const port = Number(process.env.PORT || 3000); @@ -28,21 +23,6 @@ export async function handleRequest(request: Request): Promise { return handleDashboardRoute(); } - if (url.pathname === "/api/dashboard/tiles") { - if (request.method !== "POST") return methodNotAllowed(["POST"]); - return handleDashboardTilesRoute(request); - } - - if (url.pathname === "/api/dashboard/events") { - if (request.method !== "GET") return methodNotAllowed(["GET"]); - return handleDashboardEventsRoute({ signal: request.signal }); - } - - if (url.pathname.startsWith("/api/dashboard/tile/")) { - if (request.method !== "GET") return methodNotAllowed(["GET"]); - return handleDashboardTileRoute(url.pathname); - } - if (url.pathname === "/api/agent/dashboard") { if (request.method !== "POST") return methodNotAllowed(["POST"]); return handleAgentDashboardRoute(request); diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index a4cf7ef..21ad58d 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -1,34 +1,11 @@ -import { afterAll, afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { - createDashboardTileCache, - dashboardTileCacheKey, - handleDashboardEventsRoute, - handleDashboardTilesRoute, - handleDashboardTileRoute, - loadDashboardResponse, - loadDashboardTilesResponse, - loadDashboardTileResponse, - type DashboardTileResolutionLogEvent, -} from "./dashboard"; +import { loadDashboardResponse } from "./dashboard"; describe("dashboard API route", () => { - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); - - afterEach(() => { - consoleInfo.mockClear(); - }); - - afterAll(() => { - consoleInfo.mockRestore(); - }); - - test("returns the ready dashboard shell without hydrating live datasources", async () => { - const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( - new Error("live datasource fetch should not run for the shell response"), - ); - + test("returns ready dashboard runtime state from the existing model loader", async () => { const response = await loadDashboardResponse({ + disableLiveDatasources: true, refreshSeedDocument: true, seedIfEmpty: true, }); @@ -38,762 +15,5 @@ describe("dashboard API route", () => { expect(response.document.metadata.title).toBe( dimensionLabDashboardFixture.metadata.title, ); - expect(fetch).not.toHaveBeenCalled(); - - fetch.mockRestore(); - }); - - test("reports when client-side live hydration is disabled", async () => { - const previous = process.env.DISABLE_LIVE_DATASOURCES; - process.env.DISABLE_LIVE_DATASOURCES = "1"; - - try { - const response = await loadDashboardResponse({ - refreshSeedDocument: true, - seedIfEmpty: true, - }); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready dashboard"); - expect(response.liveDatasourceHydration).toEqual({ enabled: false }); - } finally { - if (previous === undefined) { - delete process.env.DISABLE_LIVE_DATASOURCES; - } else { - process.env.DISABLE_LIVE_DATASOURCES = previous; - } - } - }); - - test("hydrates a telemetry tile independently from the dashboard shell", async () => { - const fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - - if (url.startsWith("https://prometheus.example/api/v1/query_range")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - values: [ - [1771430000, "10"], - [1771430060, "20"], - [1771430120, "42"], - ], - }, - ], - }, - }); - } - - if (url.startsWith("https://prometheus.example/api/v1/query")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - value: [1771430400, "42"], - }, - ], - }, - }); - } - - throw new Error(`Unhandled test request: ${url}`); - }); - - const response = await loadDashboardTileResponse( - { kind: "telemetry", id: "infra-ram" }, - { - fetch, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - }, - ); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready tile"); - expect(response.tile).toEqual({ kind: "telemetry", id: "infra-ram" }); - expect(response.item).toMatchObject({ - id: "infra-ram", - value: { kind: "percent", value: 42 }, - severity: "ok", - detail: "linux-infra", - sparkline: [10, 20, 42], - }); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/query?"), - expect.objectContaining({ cache: "no-store" }), - ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/query_range?"), - expect.objectContaining({ cache: "no-store" }), - ); - }); - - test("hydrates a service tile with its service group identity", async () => { - const fetch = vi.fn(async () => - jsonResponse({ - status: "UP", - ping: 42, - }), - ); - - const response = await loadDashboardTileResponse( - { kind: "service", groupId: "essentials", id: "vaultwarden" }, - { - fetch, - refreshSeedDocument: true, - seedIfEmpty: true, - }, - ); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready tile"); - expect(response.tile).toEqual({ - kind: "service", - groupId: "essentials", - id: "vaultwarden", - }); - expect(response.item).toMatchObject({ - id: "vaultwarden", - severity: "ok", - detail: "42 ms", - }); - }); - - test("caches ready tile responses until the tile ttl expires", async () => { - const cache = createDashboardTileCache(); - let now = 1_000; - const fetch = vi.fn(async () => { - now += 7; - return jsonResponse({ - status: "UP", - ping: 42, - }); - }); - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - const first = await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - const second = await loadDashboardTileResponse(tile, { - fetch, - now: () => now + 29_999, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - now += 30_001; - const third = await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - expect(first).toEqual(second); - expect(third.state).toBe("ready"); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - test("logs tile duration and cache hit or miss metadata", async () => { - let now = 1_000; - const logs: DashboardTileResolutionLogEvent[] = []; - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - const service = dimensionLabDashboardFixture.serviceGroups - .flatMap((group) => group.services) - .find((item) => item.id === tile.id); - if (!service) throw new Error("missing service fixture"); - - let cacheCalls = 0; - const tileCache = { - async resolve() { - cacheCalls += 1; - if (cacheCalls === 1) now += 7; - const cacheState = cacheCalls === 1 ? "miss" as const : "hit" as const; - - return { - cache: cacheState, - coalesced: false, - response: { - state: "ready" as const, - tile, - item: service, - }, - }; - }, - }; - - await loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache, - }); - now += 10; - await loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache, - }); - - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: false, - durationMs: 7, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - expect.objectContaining({ - cache: "hit", - coalesced: false, - durationMs: 0, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("keeps telemetry tiles cached for fifteen seconds", async () => { - const cache = createDashboardTileCache(); - let now = 1_000; - const fetch = telemetryFetch(); - const tile = { kind: "telemetry", id: "infra-ram" } as const; - - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - await loadDashboardTileResponse(tile, { - fetch, - now: () => now + 14_999, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - now += 15_001; - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - expect(fetch).toHaveBeenCalledTimes(4); - }); - - test("keeps weather module tiles cached for ten minutes", async () => { - const cache = createDashboardTileCache(); - let now = 1_000; - const fetch = vi.fn(async () => - jsonResponse({ - current: { - apparent_temperature: 19, - temperature_2m: 20, - weather_code: 0, - wind_speed_10m: 11, - }, - }), - ); - const tile = { kind: "module", id: "weather-amsterdam" } as const; - - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - await loadDashboardTileResponse(tile, { - fetch, - now: () => now + 599_999, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - now += 600_001; - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - expect(fetch).toHaveBeenCalledTimes(2); - }); - - test("coalesces concurrent tile requests for the same cache key", async () => { - const cache = createDashboardTileCache(); - const logs: DashboardTileResolutionLogEvent[] = []; - let resolveFetch: ((response: Response) => void) | undefined; - const fetch = vi.fn(() => - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - const first = loadDashboardTileResponse(tile, { - fetch, - logTileResolution: (event) => logs.push(event), - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - const second = loadDashboardTileResponse(tile, { - fetch, - logTileResolution: (event) => logs.push(event), - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - await Promise.resolve(); - expect(fetch).toHaveBeenCalledTimes(1); - - resolveFetch?.(jsonResponse({ status: "UP", ping: 42 })); - expect(await first).toEqual(await second); - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: false, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - expect.objectContaining({ - cache: "miss", - coalesced: true, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("logs coalesced metadata when shared tile requests fail", async () => { - const cache = createDashboardTileCache(); - let rejectLoad: ((error: Error) => void) | undefined; - const load = vi.fn(() => - new Promise((_resolve, reject) => { - rejectLoad = reject; - }) - ); - - const first = cache.resolve("tile-a", 30_000, 1_000, load); - const second = cache.resolve("tile-a", 30_000, 1_000, load); - - await Promise.resolve(); - expect(load).toHaveBeenCalledTimes(1); - - rejectLoad?.(new TypeError("upstream failed")); - await expect(first).rejects.toThrow("upstream failed"); - await expect(second).rejects.toMatchObject({ - cache: "miss", - coalesced: true, - cause: expect.any(TypeError), - }); - }); - - test("logs coalesced metadata for failed tile cache resolution", async () => { - const logs: DashboardTileResolutionLogEvent[] = []; - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - await expect( - loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: { - async resolve() { - throw { - cache: "miss", - cause: new TypeError("coalesced cache failed"), - coalesced: true, - }; - }, - }, - }), - ).rejects.toThrow("coalesced cache failed"); - - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: true, - errorCategory: "TypeError", - status: "error", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("logs error categories for failed tile cache resolution", async () => { - const logs: DashboardTileResolutionLogEvent[] = []; - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - await expect( - loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: { - async resolve() { - throw new TypeError("cache failed"); - }, - }, - }), - ).rejects.toThrow("cache failed"); - - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: false, - errorCategory: "TypeError", - status: "error", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("uses structured tile cache keys when identifiers contain delimiters", () => { - expect( - dashboardTileCacheKey({ kind: "service", groupId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileCacheKey({ kind: "service", groupId: "a", id: "b:c" }), - ); - expect( - dashboardTileCacheKey({ kind: "status", stripId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileCacheKey({ kind: "status", stripId: "a", id: "b:c" }), - ); - }); - - test("uses thirty-second status aggregate and five-minute static status ttl buckets", async () => { - const cache = createDashboardTileCache(); - const fetch = vi.fn(async () => jsonResponse({ status: "UP", ping: 42 })); - const serviceCheckCount = dimensionLabDashboardFixture.serviceGroups - .flatMap((group) => group.services).length; - - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "system-status" }, - { - fetch, - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "system-status" }, - { - fetch, - now: () => 31_001, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "auto-refresh" }, - { - fetch, - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "auto-refresh" }, - { - fetch, - now: () => 300_999, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "auto-refresh" }, - { - fetch, - now: () => 301_001, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - - expect(fetch).toHaveBeenCalledTimes(serviceCheckCount * 2); - }); - - test("serves tile route responses with short private cache headers", async () => { - const response = await handleDashboardTileRoute( - "/api/dashboard/tile/service/essentials/vaultwarden", - { - fetch: vi.fn(async () => jsonResponse({ status: "UP", ping: 42 })), - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: createDashboardTileCache(), - }, - ); - - expect(response.headers.get("cache-control")).toBe( - "private, max-age=5, stale-while-revalidate=30", - ); - }); - - test("serves batch tile route responses", async () => { - const response = await handleDashboardTilesRoute( - new Request("https://example.test/api/dashboard/tiles", { - method: "POST", - body: JSON.stringify({ - tiles: [ - { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - ], - }), - }), - { - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: createDashboardTileCache(), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe( - "private, max-age=5, stale-while-revalidate=30", - ); - await expect(response.json()).resolves.toMatchObject({ - state: "ready", - tiles: [ - { - state: "ready", - tile: { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - }, - ], - }); - }); - - test("streams ready dashboard tile events", async () => { - const controller = new AbortController(); - const response = await handleDashboardEventsRoute({ - refreshSeedDocument: true, - seedIfEmpty: true, - signal: controller.signal, - tileCache: { - async resolve(key) { - controller.abort(); - const tile = JSON.parse(key); - - return { - cache: "miss", - coalesced: false, - response: { - state: "ready", - tile, - item: { - id: tile.id, - label: "Status", - severity: "ok", - value: "ok", - }, - }, - }; - }, - }, - }); - - expect(response.headers.get("content-type")).toBe( - "text/event-stream; charset=utf-8", - ); - expect(response.headers.get("cache-control")).toBe("no-cache"); - const body = await response.text(); - expect(body).toContain("event: dashboard-tile"); - expect(body).toContain('"state":"ready"'); - expect(body).toContain('"tile"'); - }); - - test("rejects invalid batch tile requests", async () => { - const response = await handleDashboardTilesRoute( - new Request("https://example.test/api/dashboard/tiles", { - method: "POST", - body: JSON.stringify({ - tiles: [{ kind: "service", id: "missing-group" }], - }), - }), - ); - - expect(response.status).toBe(400); - }); - - test("resolves batch tile responses with server concurrency capped at six", async () => { - let active = 0; - let maxActive = 0; - const started: string[] = []; - const releases = new Map void>(); - const service = dimensionLabDashboardFixture.serviceGroups - .flatMap((group) => group.services)[0]; - if (!service) throw new Error("missing service fixture"); - const tiles = Array.from({ length: 7 }, (_, index) => ({ - kind: "service" as const, - groupId: "essentials", - id: `service-${index}`, - })); - - const batch = loadDashboardTilesResponse(tiles, { - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: { - async resolve(key) { - active += 1; - maxActive = Math.max(maxActive, active); - started.push(key); - await new Promise((resolve) => releases.set(key, resolve)); - active -= 1; - - return { - cache: "miss", - coalesced: false, - response: { - state: "ready", - tile: JSON.parse(key), - item: service, - }, - }; - }, - }, - }); - - await waitFor(() => started.length === 6); - expect(maxActive).toBe(6); - releases.get(started[0])?.(); - await waitFor(() => started.length === 7); - for (const release of releases.values()) release(); - - await expect(batch).resolves.toMatchObject({ - state: "ready", - tiles: expect.arrayContaining([ - expect.objectContaining({ - state: "ready", - tile: tiles[0], - }), - ]), - }); - expect(maxActive).toBe(6); - }); - - test("does not hydrate tile routes when live datasources are disabled", async () => { - const previous = process.env.DISABLE_LIVE_DATASOURCES; - process.env.DISABLE_LIVE_DATASOURCES = "1"; - const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( - new Error("live datasource fetch should not run when disabled"), - ); - - try { - const response = await loadDashboardTileResponse( - { kind: "telemetry", id: "infra-ram" }, - { - refreshSeedDocument: true, - seedIfEmpty: true, - }, - ); - - expect(response.state).toBe("disabled"); - expect(fetch).not.toHaveBeenCalled(); - } finally { - fetch.mockRestore(); - if (previous === undefined) { - delete process.env.DISABLE_LIVE_DATASOURCES; - } else { - process.env.DISABLE_LIVE_DATASOURCES = previous; - } - } }); }); - -function jsonResponse(payload: unknown): Response { - return new Response(JSON.stringify(payload), { - headers: { "content-type": "application/json" }, - }); -} - -async function waitFor(predicate: () => boolean) { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await Promise.resolve(); - } - - throw new Error("condition was not met"); -} - -function telemetryFetch() { - return vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - - if (url.startsWith("https://prometheus.example/api/v1/query_range")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - values: [ - [1771430000, "10"], - [1771430060, "20"], - [1771430120, "42"], - ], - }, - ], - }, - }); - } - - if (url.startsWith("https://prometheus.example/api/v1/query")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - value: [1771430400, "42"], - }, - ], - }, - }); - } - - throw new Error(`Unhandled test request: ${url}`); - }); -} diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index c42227c..21c30e4 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -1,138 +1,21 @@ -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; import { loadDashboardRuntime, type DashboardRuntimeOptions, type DashboardRuntimeState, } from "$lib/server/dashboard"; -import { - resolveDashboardDatasources, - resolveDashboardTile, - type DashboardTileReference, - type DashboardTileResolution, - type DatasourceResolutionOptions, -} from "$lib/server/datasources"; +import { resolveDashboardDatasources } from "$lib/server/datasources"; export interface LoadDashboardResponseOptions extends Pick< DashboardRuntimeOptions, "refreshSeedDocument" | "seedIfEmpty" | "seedDocument" - >, - DatasourceResolutionOptions { + > { disableLiveDatasources?: boolean; - hydrateLiveDatasources?: boolean; - logTileResolution?: (event: DashboardTileResolutionLogEvent) => void; - now?: () => number; - tileCache?: DashboardTileCache; -} - -export interface DashboardEventsRouteOptions extends LoadDashboardResponseOptions { - signal?: AbortSignal; -} - -interface DashboardTileCacheEntry { - expiresAt: number; - response: DashboardTileResolution; -} - -export interface DashboardTileCache { - resolve( - key: string, - ttlMs: number, - now: number, - load: () => Promise, - ): Promise; -} - -const dashboardTileResponseHeaders = { - "Cache-Control": "private, max-age=5, stale-while-revalidate=30", -}; - -const dashboardBatchTileConcurrency = 6; - -const defaultDashboardTileCache = createDashboardTileCache(); - -interface DashboardTileCacheResult { - cache: "hit" | "miss"; - coalesced: boolean; - response: DashboardTileResolution; -} - -export interface DashboardTileResolutionLogEvent { - cache: "bypass" | "hit" | "miss"; - coalesced: boolean; - durationMs: number; - errorCategory?: string; - status: DashboardTileResolution["state"] | "error"; - tileKey: string; -} - -export interface DashboardTilesBatchResponse { - state: "ready"; - tiles: DashboardTileResolution[]; -} - -export function createDashboardTileCache(): DashboardTileCache { - const entries = new Map(); - const inFlight = new Map>(); - - return { - async resolve(key, ttlMs, now, load) { - const cached = entries.get(key); - if (cached && cached.expiresAt > now) { - return { - cache: "hit", - coalesced: false, - response: cached.response, - }; - } - - const active = inFlight.get(key); - if (active) { - return active - .then((response) => ({ - cache: "miss" as const, - coalesced: true, - response, - })) - .catch((error) => { - throw new DashboardTileCacheResolutionError(error, { - cache: "miss", - coalesced: true, - }); - }); - } - - const request = load() - .then((response) => { - if (response.state === "ready") { - entries.set(key, { - expiresAt: now + ttlMs, - response, - }); - } - - return response; - }) - .finally(() => { - inFlight.delete(key); - }); - - inFlight.set(key, request); - return request.then((response) => ({ - cache: "miss" as const, - coalesced: false, - response, - })); - }, - }; } export async function loadDashboardResponse( options: LoadDashboardResponseOptions = {}, ): Promise { - const liveHydrationEnabled = - !options.disableLiveDatasources && - process.env.DISABLE_LIVE_DATASOURCES !== "1"; const dashboard = loadDashboardRuntime(undefined, { refreshSeedDocument: options.refreshSeedDocument ?? true, seedIfEmpty: options.seedIfEmpty ?? true, @@ -143,467 +26,16 @@ export async function loadDashboardResponse( return dashboard; } - if ( - !options.hydrateLiveDatasources || - options.disableLiveDatasources || - process.env.DISABLE_LIVE_DATASOURCES === "1" - ) { - return { - ...dashboard, - liveDatasourceHydration: { - enabled: liveHydrationEnabled, - }, - }; + if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") { + return dashboard; } return { ...dashboard, - document: await resolveDashboardDatasources(dashboard.document, options), - liveDatasourceHydration: { - enabled: false, - }, - }; -} - -export async function loadDashboardTileResponse( - tile: DashboardTileReference, - options: LoadDashboardResponseOptions = {}, -): Promise { - if ( - options.disableLiveDatasources || - process.env.DISABLE_LIVE_DATASOURCES === "1" - ) { - const tileKey = dashboardTileCacheKey(tile); - const startedAt = options.now?.() ?? Date.now(); - const response = { - state: "disabled", - tile, - message: "Live datasource hydration is disabled.", - } satisfies DashboardTileResolution; - logDashboardTileResolution(options, { - cache: "bypass", - coalesced: false, - durationMs: elapsedDashboardTileMs(startedAt, options), - status: response.state, - tileKey, - }); - return response; - } - - const tileKey = dashboardTileCacheKey(tile); - const startedAt = options.now?.() ?? Date.now(); - - const dashboard = loadDashboardRuntime(undefined, { - refreshSeedDocument: options.refreshSeedDocument ?? true, - seedIfEmpty: options.seedIfEmpty ?? true, - seedDocument: options.seedDocument, - }); - - if (dashboard.state !== "ready") { - const response = { - state: "not_found", - tile, - message: `Dashboard is not ready: ${dashboard.state}`, - } satisfies DashboardTileResolution; - logDashboardTileResolution(options, { - cache: "bypass", - coalesced: false, - durationMs: elapsedDashboardTileMs(startedAt, options), - status: response.state, - tileKey, - }); - return response; - } - - const cache = options.tileCache || defaultDashboardTileCache; - const now = options.now?.() ?? Date.now(); - - try { - const result = await cache.resolve( - tileKey, - dashboardTileTtlMs(dashboard.document, tile), - now, - () => resolveDashboardTile(dashboard.document, tile, options), - ); - logDashboardTileResolution(options, { - cache: result.cache, - coalesced: result.coalesced, - durationMs: elapsedDashboardTileMs(startedAt, options), - status: result.response.state, - tileKey, - }); - return result.response; - } catch (error) { - const cacheError = dashboardTileCacheResolutionError(error); - logDashboardTileResolution(options, { - cache: cacheError?.cache ?? "miss", - coalesced: cacheError?.coalesced ?? false, - durationMs: elapsedDashboardTileMs(startedAt, options), - errorCategory: dashboardTileErrorCategory(cacheError?.cause ?? error), - status: "error", - tileKey, - }); - throw cacheError?.cause ?? error; - } -} - -export async function loadDashboardTilesResponse( - tiles: DashboardTileReference[], - options: LoadDashboardResponseOptions = {}, -): Promise { - return { - state: "ready", - tiles: await resolveDashboardTilesBatch(tiles, options), + document: await resolveDashboardDatasources(dashboard.document), }; } export async function handleDashboardRoute(): Promise { return Response.json(await loadDashboardResponse()); } - -export async function handleDashboardTileRoute( - pathname: string, - options: LoadDashboardResponseOptions = {}, -): Promise { - const tile = parseDashboardTilePath(pathname); - if (!tile) { - return Response.json( - { ok: false, message: "Invalid dashboard tile route" }, - { status: 404 }, - ); - } - - const response = await loadDashboardTileResponse(tile, options); - return Response.json(response, { - status: response.state === "not_found" ? 404 : 200, - headers: dashboardTileResponseHeaders, - }); -} - -export async function handleDashboardTilesRoute( - request: Request, - options: LoadDashboardResponseOptions = {}, -): Promise { - const tiles = await parseDashboardTilesBatchRequest(request); - if (!tiles) { - return Response.json( - { ok: false, message: "Invalid dashboard tiles request" }, - { status: 400 }, - ); - } - - return Response.json(await loadDashboardTilesResponse(tiles, options), { - headers: dashboardTileResponseHeaders, - }); -} - -export async function handleDashboardEventsRoute( - options: DashboardEventsRouteOptions = {}, -): Promise { - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - const dashboard = await loadDashboardResponse({ - ...options, - hydrateLiveDatasources: false, - }); - - if (dashboard.state !== "ready" || options.signal?.aborted) { - controller.close(); - return; - } - - try { - for (const tile of dashboardEventTiles(dashboard.document)) { - if (options.signal?.aborted) break; - const resolution = await loadDashboardTileResponse(tile, options); - if (resolution.state === "ready") { - controller.enqueue( - encoder.encode(dashboardTileEventChunk(resolution)), - ); - } - } - } finally { - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Content-Type": "text/event-stream; charset=utf-8", - "X-Accel-Buffering": "no", - }, - }); -} - -export function dashboardTileCacheKey(tile: DashboardTileReference): string { - return JSON.stringify(tile); -} - -function logDashboardTileResolution( - options: LoadDashboardResponseOptions, - event: DashboardTileResolutionLogEvent, -): void { - if (options.logTileResolution) { - options.logTileResolution(event); - return; - } - - console.info("dashboard.tile", event); -} - -function elapsedDashboardTileMs( - startedAt: number, - options: LoadDashboardResponseOptions, -): number { - const now = options.now?.() ?? Date.now(); - return Math.max(0, now - startedAt); -} - -function dashboardTileErrorCategory(error: unknown): string { - if ( - typeof error === "object" && - error !== null && - "name" in error && - typeof error.name === "string" - ) { - return error.name; - } - - return "unknown"; -} - -async function resolveDashboardTilesBatch( - tiles: DashboardTileReference[], - options: LoadDashboardResponseOptions, -): Promise { - const results: DashboardTileResolution[] = new Array(tiles.length); - let nextIndex = 0; - - async function worker() { - while (nextIndex < tiles.length) { - const index = nextIndex; - nextIndex += 1; - results[index] = await loadDashboardTileResponse(tiles[index], options); - } - } - - await Promise.all( - Array.from( - { length: Math.min(dashboardBatchTileConcurrency, tiles.length) }, - () => worker(), - ), - ); - - return results; -} - -async function parseDashboardTilesBatchRequest( - request: Request, -): Promise { - try { - const body = await request.json() as unknown; - if ( - typeof body !== "object" || - body === null || - !("tiles" in body) || - !Array.isArray(body.tiles) - ) { - return null; - } - - const tiles = body.tiles.map(parseDashboardTileReference); - return tiles.every((tile): tile is DashboardTileReference => tile !== null) - ? tiles - : null; - } catch { - return null; - } -} - -function parseDashboardTileReference(value: unknown): DashboardTileReference | null { - if (typeof value !== "object" || value === null || !("kind" in value)) { - return null; - } - - if ( - (value.kind === "telemetry" || value.kind === "module") && - "id" in value && - typeof value.id === "string" - ) { - return { kind: value.kind, id: value.id }; - } - - if ( - value.kind === "service" && - "groupId" in value && - typeof value.groupId === "string" && - "id" in value && - typeof value.id === "string" - ) { - return { - kind: "service", - groupId: value.groupId, - id: value.id, - }; - } - - if ( - value.kind === "status" && - "stripId" in value && - typeof value.stripId === "string" && - "id" in value && - typeof value.id === "string" - ) { - return { - kind: "status", - stripId: value.stripId, - id: value.id, - }; - } - - return null; -} - -function dashboardEventTiles(document: DashboardDocument): DashboardTileReference[] { - const status = document.statusStrips.flatMap((strip) => - strip.items.map((item): DashboardTileReference => ({ - kind: "status", - stripId: strip.id, - id: item.id, - })), - ); - const telemetry = document.telemetry - .filter((card) => card.datasource?.type === "external") - .map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id })); - const modules = (document.modules || []) - .filter((module) => - module.datasource?.type === "external" || - module.id === "runtime-health-summary" - ) - .map((module): DashboardTileReference => ({ kind: "module", id: module.id })); - const services = document.serviceGroups.flatMap((group) => - group.services - .filter((service) => service.datasource?.type === "external") - .map((service): DashboardTileReference => ({ - kind: "service", - groupId: group.id, - id: service.id, - })), - ); - - return [...status, ...telemetry, ...modules, ...services]; -} - -function dashboardTileEventChunk(resolution: DashboardTileResolution): string { - return [ - "event: dashboard-tile", - `data: ${JSON.stringify(resolution)}`, - "", - "", - ].join("\n"); -} - -class DashboardTileCacheResolutionError extends Error { - readonly cache: "hit" | "miss"; - readonly coalesced: boolean; - override readonly cause: unknown; - - constructor( - cause: unknown, - metadata: Pick, - ) { - super("Dashboard tile cache resolution failed"); - this.name = "DashboardTileCacheResolutionError"; - this.cause = cause; - this.cache = metadata.cache; - this.coalesced = metadata.coalesced; - } -} - -function dashboardTileCacheResolutionError( - error: unknown, -): DashboardTileCacheResolutionFailure | null { - if ( - typeof error === "object" && - error !== null && - "cache" in error && - (error.cache === "hit" || error.cache === "miss") && - "coalesced" in error && - typeof error.coalesced === "boolean" && - "cause" in error - ) { - return { - cache: error.cache, - cause: error.cause, - coalesced: error.coalesced, - }; - } - - return null; -} - -interface DashboardTileCacheResolutionFailure { - cache: "hit" | "miss"; - cause: unknown; - coalesced: boolean; -} - -function dashboardTileTtlMs( - document: DashboardDocument, - tile: DashboardTileReference, -): number { - if (tile.kind === "telemetry") return 15_000; - if (tile.kind === "service") return 30_000; - - if (tile.kind === "module") { - const module = document.modules?.find((item) => item.id === tile.id); - if ( - module?.datasource?.type === "external" && - module.datasource.adapter === "weather" - ) { - return 10 * 60_000; - } - return 30_000; - } - - if (["system-status", "uptime", "load-avg"].includes(tile.id)) { - return 30_000; - } - - return 5 * 60_000; -} - -function parseDashboardTilePath(pathname: string): DashboardTileReference | null { - const parts = pathname.split("/").filter(Boolean); - const [, dashboard, tileRoot, kind, firstId, secondId] = parts; - if (dashboard !== "dashboard" || tileRoot !== "tile" || !kind || !firstId) { - return null; - } - - const id = decodeURIComponent(firstId); - if (kind === "telemetry" || kind === "module") { - return { kind, id }; - } - - if (kind === "service" && secondId) { - return { - kind, - groupId: id, - id: decodeURIComponent(secondId), - }; - } - - if (kind === "status" && secondId) { - return { - kind, - stripId: id, - id: decodeURIComponent(secondId), - }; - } - - return null; -} diff --git a/apps/web/tests/e2e/dashboard.spec.ts b/apps/web/tests/e2e/dashboard.spec.ts index 3eb0944..48eac8b 100644 --- a/apps/web/tests/e2e/dashboard.spec.ts +++ b/apps/web/tests/e2e/dashboard.spec.ts @@ -16,6 +16,7 @@ const linkedServiceIds = [ "open-webui", "comfyui", "models", + "prompt-registry", "adminer", "assistant", "suna", @@ -103,7 +104,7 @@ test.describe("dashboard page QA gate", () => { expect(metrics.runtimeBottom).toBeLessThanOrEqual(956); expect(metrics.footerBottom).toBeLessThanOrEqual(956); expect(metrics.telemetryCardCount).toBe(16); - expect(metrics.serviceRowCount).toBe(27); + expect(metrics.serviceRowCount).toBe(28); expect(metrics.footerCellCount).toBe(5); expect(metrics.clippedItems).toEqual([]); }); diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png index a1c0ada..c548a8a 100644 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png differ diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png index f5d5163..a007213 100644 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png differ diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png index 48293a4..0542e73 100644 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png differ diff --git a/packages/ui b/packages/ui deleted file mode 160000 index a7a4720..0000000 --- a/packages/ui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a7a472083555152b8e1a2dc018d3be5b3b10d60b diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts new file mode 100644 index 0000000..34237be --- /dev/null +++ b/packages/ui/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(js|ts|tsx)"], + addons: [ + "@storybook/addon-a11y", + "@storybook/addon-vitest", + ], + framework: { + name: "@storybook/react-vite", + options: {}, + }, +}; + +export default config; diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts new file mode 100644 index 0000000..87df132 --- /dev/null +++ b/packages/ui/.storybook/preview.ts @@ -0,0 +1,51 @@ +import "../src/styles.css"; +import type { Preview } from "@storybook/react-vite"; + +const preview: Preview = { + decorators: [ + (Story, context) => { + const theme = context.globals.theme === "light" ? "light" : "dark"; + + if (typeof document !== "undefined") { + document.documentElement.setAttribute("data-ui-theme", theme); + } + + return Story(); + }, + ], + globalTypes: { + theme: { + description: "Dashboard component theme", + defaultValue: "dark", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "dark", title: "Dark" }, + { value: "light", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, + parameters: { + backgrounds: { + default: "canvas", + values: [ + { name: "canvas", value: "#0b0f0d" }, + { name: "raised", value: "#151d18" }, + { name: "light canvas", value: "#eef2e7" }, + { name: "light raised", value: "#f1f5ea" }, + ], + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + layout: "fullscreen", + }, +}; + +export default preview; diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..d951369 --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "src/components", + "utils": "src/utils", + "ui": "src/primitives", + "lib": "src", + "hooks": "src/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..a30db52 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,79 @@ +{ + "name": "@dimensionlab/ui", + "version": "0.0.1", + "private": true, + "type": "module", + "sideEffects": ["*.css", "**/*.css"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + }, + "./foundation": { + "types": "./dist/components/foundation/index.d.ts", + "development": "./src/components/foundation/index.ts", + "default": "./dist/components/foundation/index.js" + }, + "./frames": { + "types": "./dist/components/frames/index.d.ts", + "development": "./src/components/frames/index.ts", + "default": "./dist/components/frames/index.js" + }, + "./operations": { + "types": "./dist/components/operations/index.d.ts", + "development": "./src/components/operations/index.ts", + "default": "./dist/components/operations/index.js" + }, + "./telemetry": { + "types": "./dist/components/telemetry/index.d.ts", + "development": "./src/components/telemetry/index.ts", + "default": "./dist/components/telemetry/index.js" + }, + "./styles.css": { + "development": "./src/styles.css", + "default": "./dist/styles.css" + }, + "./tokens.css": { + "development": "./src/tokens.css", + "default": "./dist/tokens.css" + } + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/components && cp src/styles.css dist/styles.css && cp src/tokens.css dist/tokens.css && cp src/components/styles.css dist/components/styles.css", + "check": "tsc --noEmit", + "test": "vitest run", + "test:unit": "vitest run", + "storybook": "storybook dev -p 6006 --host 0.0.0.0", + "build-storybook": "storybook build" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@iconify/react": "^6.0.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.21.0", + "radix-ui": "^1.6.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "uplot": "^1.6.32" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@storybook/addon-a11y": "^10.4.6", + "@storybook/addon-vitest": "^10.4.6", + "@storybook/react-vite": "^10.4.6", + "@types/bun": "^1.3.14", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "bun-types": "^1.3.14", + "storybook": "^10.4.6", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.9" + } +} diff --git a/packages/ui/src/components/foundation/Badge.tsx b/packages/ui/src/components/foundation/Badge.tsx new file mode 100644 index 0000000..5bdee48 --- /dev/null +++ b/packages/ui/src/components/foundation/Badge.tsx @@ -0,0 +1,11 @@ +import type { UiSeverity } from "../../types"; +import { StatusBadge } from "./StatusBadge"; + +export interface BadgeProps { + label: string; + severity?: UiSeverity; +} + +export function Badge({ label, severity = "neutral" }: BadgeProps) { + return ; +} diff --git a/packages/ui/src/components/foundation/Button.tsx b/packages/ui/src/components/foundation/Button.tsx new file mode 100644 index 0000000..8ae3b4e --- /dev/null +++ b/packages/ui/src/components/foundation/Button.tsx @@ -0,0 +1,39 @@ +import type { ButtonHTMLAttributes } from "react"; +import { IconGlyph } from "./IconGlyph"; + +export interface ButtonProps + extends Omit, "type"> { + label: string; + variant?: "primary" | "secondary" | "danger" | "ghost"; + size?: "default" | "compact"; + icon?: string; + loading?: boolean; + type?: "button" | "submit" | "reset"; +} + +export function Button({ + label, + variant = "primary", + size = "default", + icon, + disabled = false, + loading = false, + type = "button", + className = "", + ...buttonProps +}: ButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/foundation/IconButton.tsx b/packages/ui/src/components/foundation/IconButton.tsx new file mode 100644 index 0000000..92fcb46 --- /dev/null +++ b/packages/ui/src/components/foundation/IconButton.tsx @@ -0,0 +1,33 @@ +import type { ButtonHTMLAttributes } from "react"; +import { IconGlyph } from "./IconGlyph"; + +export interface IconButtonProps + extends Omit, "type"> { + icon: string; + label: string; + active?: boolean; + type?: "button" | "submit" | "reset"; +} + +export function IconButton({ + icon, + label, + active = false, + disabled = false, + type = "button", + className = "", + ...buttonProps +}: IconButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/foundation/IconGlyph.tsx b/packages/ui/src/components/foundation/IconGlyph.tsx new file mode 100644 index 0000000..d21feb1 --- /dev/null +++ b/packages/ui/src/components/foundation/IconGlyph.tsx @@ -0,0 +1,21 @@ +import { Icon } from "@iconify/react"; + +export interface IconGlyphProps { + name?: string; + label?: string; + size?: "sm" | "md" | "lg"; +} + +export function IconGlyph({ name, label, size = "md" }: IconGlyphProps) { + return ( + + {name ? : null} + + ); +} diff --git a/packages/ui/src/components/foundation/ProgressMeter.tsx b/packages/ui/src/components/foundation/ProgressMeter.tsx new file mode 100644 index 0000000..c3f3aad --- /dev/null +++ b/packages/ui/src/components/foundation/ProgressMeter.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties } from "react"; +import { clampPercent } from "../../format"; +import type { UiSeverity } from "../../types"; + +export interface ProgressMeterProps { + value?: number; + severity?: UiSeverity; + label?: string; +} + +export function ProgressMeter({ + value, + severity = "neutral", + label = "Progress", +}: ProgressMeterProps) { + const progress = value === undefined ? null : clampPercent(value); + + return ( +
+ {progress !== null ? ( + + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/foundation/Separator.tsx b/packages/ui/src/components/foundation/Separator.tsx new file mode 100644 index 0000000..ca1a955 --- /dev/null +++ b/packages/ui/src/components/foundation/Separator.tsx @@ -0,0 +1,19 @@ +export interface SeparatorProps { + orientation?: "horizontal" | "vertical"; + dense?: boolean; +} + +export function Separator({ + orientation = "horizontal", + dense = false, +}: SeparatorProps) { + return ( +
+ ); +} diff --git a/packages/ui/src/components/foundation/StatusBadge.tsx b/packages/ui/src/components/foundation/StatusBadge.tsx new file mode 100644 index 0000000..d2b01b3 --- /dev/null +++ b/packages/ui/src/components/foundation/StatusBadge.tsx @@ -0,0 +1,14 @@ +import type { UiSeverity } from "../../types"; + +export interface StatusBadgeProps { + label: string; + severity?: UiSeverity; +} + +export function StatusBadge({ label, severity = "neutral" }: StatusBadgeProps) { + return ( + + {label} + + ); +} diff --git a/packages/ui/src/components/foundation/ThemeToggle.tsx b/packages/ui/src/components/foundation/ThemeToggle.tsx new file mode 100644 index 0000000..5d83031 --- /dev/null +++ b/packages/ui/src/components/foundation/ThemeToggle.tsx @@ -0,0 +1,36 @@ +import type { UiTheme } from "../../theme"; +import { getNextUiTheme } from "../../theme"; +import { IconGlyph } from "./IconGlyph"; + +export interface ThemeToggleProps { + theme: UiTheme; + onThemeChange: (theme: UiTheme) => void; +} + +export function ThemeToggle({ theme, onThemeChange }: ThemeToggleProps) { + const nextTheme = getNextUiTheme(theme); + + return ( + + ); +} diff --git a/packages/ui/src/components/foundation/index.ts b/packages/ui/src/components/foundation/index.ts new file mode 100644 index 0000000..b82cd47 --- /dev/null +++ b/packages/ui/src/components/foundation/index.ts @@ -0,0 +1,8 @@ +export * from "./Badge"; +export * from "./Button"; +export * from "./IconButton"; +export * from "./IconGlyph"; +export * from "./ProgressMeter"; +export * from "./Separator"; +export * from "./StatusBadge"; +export * from "./ThemeToggle"; diff --git a/packages/ui/src/components/frames/CornerBracketFrame.tsx b/packages/ui/src/components/frames/CornerBracketFrame.tsx new file mode 100644 index 0000000..1dfd4f7 --- /dev/null +++ b/packages/ui/src/components/frames/CornerBracketFrame.tsx @@ -0,0 +1,26 @@ +export interface CornerBracketFrameProps { + density?: "regular" | "tight"; + size?: "sm" | "md" | "lg"; + tone?: "neutral" | "accent" | "danger"; +} + +export function CornerBracketFrame({ + density = "regular", + size = "md", + tone = "neutral", +}: CornerBracketFrameProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/frames/DashboardFrame.tsx b/packages/ui/src/components/frames/DashboardFrame.tsx new file mode 100644 index 0000000..08029cf --- /dev/null +++ b/packages/ui/src/components/frames/DashboardFrame.tsx @@ -0,0 +1,59 @@ +import { useId } from "react"; +import type { ReactNode } from "react"; +import type { UiDashboardPreview } from "../../types"; +import { ModuleCard } from "./ModuleCard"; +import { ServicePanel } from "../operations/ServicePanel"; +import { StatusStrip } from "../operations/StatusStrip"; +import { TelemetryGrid } from "../telemetry/TelemetryGrid"; + +export interface DashboardFrameProps { + actions?: ReactNode; + dashboard: UiDashboardPreview; + titleId?: string; +} + +export function DashboardFrame({ + actions, + dashboard, + titleId, +}: DashboardFrameProps) { + const generatedTitleId = useId(); + const resolvedTitleId = titleId || `${generatedTitleId}-title`; + + return ( +
+
+
+
+ {dashboard.eyebrow ?

{dashboard.eyebrow}

: null} +

{dashboard.title}

+ {dashboard.subtitle ? {dashboard.subtitle} : null} +
+ {actions ? ( +
{actions}
+ ) : null} +
+ {dashboard.modules.length ? ( +
+ {dashboard.modules.map((module) => ( + + ))} +
+ ) : null} +
+ + + +
+ {dashboard.serviceGroups.map((group) => ( + + ))} +
+ + +
+ ); +} diff --git a/packages/ui/src/components/frames/DashboardHeader.tsx b/packages/ui/src/components/frames/DashboardHeader.tsx new file mode 100644 index 0000000..3f418fa --- /dev/null +++ b/packages/ui/src/components/frames/DashboardHeader.tsx @@ -0,0 +1,30 @@ +import { useId } from "react"; +import type { UiModuleBlock } from "../../types"; +import { ModuleCard } from "./ModuleCard"; + +export interface DashboardHeaderProps { + title: string; + subtitle?: string; + eyebrow?: string; + module?: UiModuleBlock; +} + +export function DashboardHeader({ + title, + subtitle, + eyebrow, + module, +}: DashboardHeaderProps) { + const titleId = useId(); + + return ( +
+
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {subtitle ? {subtitle} : null} +
+ {module ? : null} +
+ ); +} diff --git a/packages/ui/src/components/frames/DiagonalStripeField.tsx b/packages/ui/src/components/frames/DiagonalStripeField.tsx new file mode 100644 index 0000000..b4c60f8 --- /dev/null +++ b/packages/ui/src/components/frames/DiagonalStripeField.tsx @@ -0,0 +1,24 @@ +export interface DiagonalStripeFieldProps { + density?: "open" | "regular" | "tight"; + direction?: "forward" | "backward"; + size?: "sm" | "md" | "lg"; + tone?: "neutral" | "accent" | "warning" | "danger"; +} + +export function DiagonalStripeField({ + density = "regular", + direction = "forward", + size = "md", + tone = "accent", +}: DiagonalStripeFieldProps) { + return ( +