diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml new file mode 100644 index 0000000..198a8aa --- /dev/null +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -0,0 +1,85 @@ +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/README.md b/README.md index 00d9708..85177c5 100644 --- a/README.md +++ b/README.md @@ -161,3 +161,27 @@ 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/package.json b/apps/web/package.json index 790663c..015fc55 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": "vitest run", - "test:unit": "vitest run", + "test": "bun --bun vitest run", + "test:unit": "bun --bun 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 4db0cbc..c1add67 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -4,6 +4,7 @@ import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures" import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; import { AppStateView, + dashboardTileMatchKey, dashboardHydrationTiles, restoreDashboardTileSnapshots, } from "./App"; @@ -159,4 +160,79 @@ describe("React app dashboard state view", () => { 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 16e5c5e..403bef9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,12 +10,15 @@ import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { attachDashboardRefreshLifecycle, collectVisibleDashboardModelIds, + createDashboardPerformanceMarks, createDashboardRefreshDelay, createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, + isPersistableDashboardTileSnapshot, runViewportAwareDashboardHydrationQueue, shouldPauseDashboardRefresh, + subscribeToDashboardTileEvents, waitForDashboardHydrationIdle, type DashboardIntersectionObserverFactory, type DashboardTileReference, @@ -51,6 +54,11 @@ type DashboardTileResponse = message: string; }; +type DashboardTilesBatchResponse = { + state: "ready"; + tiles: DashboardTileResponse[]; +}; + type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; const dashboardTileHydrationConcurrency = 6; @@ -171,9 +179,11 @@ 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( @@ -206,6 +216,8 @@ export default function App() { function pauseRefreshes() { clearRefreshTimer(); + unsubscribeTileEvents?.(); + unsubscribeTileEvents = undefined; requestAborter.abortActiveRequests(); setHydratingItemIds(new Set()); } @@ -222,6 +234,7 @@ export default function App() { if (cancelled || shellSignal.aborted || refreshPaused()) return; refreshDelay.recordSuccess(); + performanceMarks.markShellLoad(); const restored = restoreDashboardTileSnapshots( nextDashboard, nextDashboard.state === "ready" @@ -258,6 +271,14 @@ export default function App() { schemaVersion: restored.dashboard.schemaVersion, }, ); + subscribeDashboardTileEvents( + currentRun, + tileSignal, + { + currentRevisionId: restored.dashboard.currentRevisionId, + schemaVersion: restored.dashboard.schemaVersion, + }, + ); } else { setHydratingItemIds(new Set()); } @@ -289,6 +310,7 @@ export default function App() { const modelIds = tiles.map(dashboardTileModelId); void runViewportAwareDashboardHydrationQueue({ + batchSize: dashboardTileHydrationConcurrency, collectVisibleModelIds: async () => { await waitForDashboardRenderFrame(signal); return collectVisibleDashboardModelIds({ @@ -318,7 +340,26 @@ export default function App() { 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({ @@ -330,6 +371,93 @@ export default function App() { }); } + 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), + })), + ); + } + } + async function hydrateDashboardTile( tile: DashboardTileReference, run: number, @@ -342,31 +470,23 @@ export default function App() { const response = await fetch(dashboardTileUrl(tile), { signal }); const tileResponse = (await response.json()) as DashboardTileResponse; - if ( - cancelled || - signal.aborted || - run !== hydrationRun || - !response.ok || - tileResponse.state !== "ready" - ) { + if (!response.ok) { + return "failed"; + } + + const result = applyDashboardTileHydrationResponse( + tile, + tileResponse, + run, + signal, + snapshotContext, + ); + if (result !== "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, - }); return "ready"; } catch (error) { if (!isAbortError(error) && !cancelled) { @@ -375,13 +495,58 @@ export default function App() { } return "aborted"; } finally { - if (!cancelled && !signal.aborted && run === hydrationRun) { - setHydratingItemIds((current) => { - const next = new Set(current); - next.delete(key); - return next; - }); - } + 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; + }); } } @@ -398,6 +563,7 @@ export default function App() { return () => { cancelled = true; clearRefreshTimer(); + unsubscribeTileEvents?.(); requestAborter.abortActiveRequests(); detachRefreshLifecycle(); }; @@ -497,6 +663,20 @@ function isAbortError(error: unknown): boolean { ); } +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[], @@ -511,7 +691,9 @@ export function restoreDashboardTileSnapshots( }; } - return snapshots.reduce( + return snapshots.filter((snapshot) => + isPersistableDashboardTileSnapshot(snapshot.response.tile) + ).reduce( (current, snapshot) => ({ dashboard: { ...current.dashboard, @@ -621,6 +803,10 @@ function dashboardTileKey(tile: DashboardTileReference): string { 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; diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index d94b0c4..77ea795 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -2,14 +2,18 @@ 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"; @@ -119,6 +123,25 @@ describe("dashboard refresh lifecycle", () => { 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> = []; @@ -131,6 +154,8 @@ describe("dashboard refresh lifecycle", () => { 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"); @@ -139,11 +164,18 @@ describe("dashboard refresh lifecycle", () => { }); await waitFor(() => calls.includes("idle")); - expect(calls).toEqual(["b", "idle"]); + expect(calls).toEqual(["b", "visible-settled", "idle"]); idleReleases.shift()?.(); await hydration; - expect(calls).toEqual(["b", "idle", "a", "c"]); + expect(calls).toEqual([ + "b", + "visible-settled", + "idle", + "a", + "c", + "all-settled", + ]); }); test("splits visible hydration items while preserving document order", () => { @@ -247,6 +279,93 @@ describe("dashboard refresh lifecycle", () => { 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(); @@ -399,6 +518,77 @@ describe("dashboard refresh lifecycle", () => { }, ]); }); + + 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) { diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index a77fdd1..7c50359 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -152,11 +152,15 @@ export async function runDashboardHydrationQueue( } 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; } @@ -173,26 +177,58 @@ export async function runViewportAwareDashboardHydrationQueue( visibleModelIds, }); - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items: visible, - signal: options.signal, - }); + await runDashboardHydrationItems(options, visible); + if (options.signal.aborted) return; + options.onVisibleItemsSettled?.(); - if (!deferred.length || options.signal.aborted) return; + if (!deferred.length) { + options.onAllItemsSettled?.(); + return; + } await options.waitForIdle(); if (options.signal.aborted) return; - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items: deferred, + 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[]; @@ -408,6 +444,96 @@ export function createDashboardRefreshDelay( }; } +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() { @@ -491,7 +617,9 @@ export function createDashboardTileSnapshotStore( return { currentRevisionId: payload.currentRevisionId, schemaVersion: payload.schemaVersion, - tiles: payload.tiles.filter(isDashboardTileSnapshotRecord), + tiles: payload.tiles + .filter(isDashboardTileSnapshotRecord) + .filter((record) => isPersistableDashboardTileSnapshot(record.tile)), version: 1, }; } catch { @@ -534,6 +662,8 @@ export function createDashboardTileSnapshotStore( response: Extract; }, ): void { + if (!isPersistableDashboardTileSnapshot(input.response.tile)) return; + const payload = matchingPayload(input); const key = dashboardTileSnapshotKey(input.response.tile); const nextRecord: DashboardTileSnapshotRecord = { @@ -594,6 +724,14 @@ 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 { diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts index 31c01d9..1a97c3b 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts @@ -128,7 +128,6 @@ 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 6cc544b..59162e6 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.ts @@ -431,14 +431,6 @@ 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 7851ebb..5d93422 100644 --- a/apps/web/src/lib/server/dashboard.test.ts +++ b/apps/web/src/lib/server/dashboard.test.ts @@ -66,9 +66,6 @@ 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"); }); @@ -138,13 +135,5 @@ 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/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index c1ccaca..58cf203 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -1,4 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -42,6 +44,8 @@ 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"]), @@ -253,6 +257,186 @@ 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 = @@ -269,3 +453,126 @@ 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 new file mode 100644 index 0000000..af1035d --- /dev/null +++ b/apps/web/src/server/index.test.ts @@ -0,0 +1,77 @@ +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 ba2135f..9cbcbd9 100644 --- a/apps/web/src/server/index.ts +++ b/apps/web/src/server/index.ts @@ -1,6 +1,11 @@ import { extname, normalize } from "node:path"; import { handleAgentDashboardRoute } from "./routes/agent-dashboard"; -import { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard"; +import { + handleDashboardEventsRoute, + handleDashboardRoute, + handleDashboardTileRoute, + handleDashboardTilesRoute, +} from "./routes/dashboard"; const host = process.env.HOST || "0.0.0.0"; const port = Number(process.env.PORT || 3000); @@ -23,6 +28,16 @@ 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); diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index 9b11079..a4cf7ef 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -1,14 +1,28 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterAll, afterEach, describe, expect, test, vi } from "vitest"; import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; import { createDashboardTileCache, dashboardTileCacheKey, + handleDashboardEventsRoute, + handleDashboardTilesRoute, handleDashboardTileRoute, loadDashboardResponse, + loadDashboardTilesResponse, loadDashboardTileResponse, + type DashboardTileResolutionLogEvent, } 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"), @@ -154,12 +168,13 @@ describe("dashboard API route", () => { test("caches ready tile responses until the tile ttl expires", async () => { const cache = createDashboardTileCache(); let now = 1_000; - const fetch = vi.fn(async () => - jsonResponse({ + 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, { @@ -190,6 +205,68 @@ describe("dashboard API route", () => { 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; @@ -268,6 +345,7 @@ describe("dashboard API route", () => { 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) => { @@ -278,6 +356,7 @@ describe("dashboard API route", () => { const first = loadDashboardTileResponse(tile, { fetch, + logTileResolution: (event) => logs.push(event), now: () => 1_000, refreshSeedDocument: true, seedIfEmpty: true, @@ -285,6 +364,7 @@ describe("dashboard API route", () => { }); const second = loadDashboardTileResponse(tile, { fetch, + logTileResolution: (event) => logs.push(event), now: () => 1_000, refreshSeedDocument: true, seedIfEmpty: true, @@ -296,6 +376,104 @@ describe("dashboard API route", () => { 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", () => { @@ -388,6 +566,154 @@ describe("dashboard API route", () => { ); }); + 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"; @@ -423,6 +749,15 @@ function jsonResponse(payload: unknown): Response { }); } +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); diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index ce9c4e6..c42227c 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -20,10 +20,15 @@ export interface LoadDashboardResponseOptions 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; @@ -35,15 +40,37 @@ export interface DashboardTileCache { ttlMs: number, now: number, load: () => Promise, - ): 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>(); @@ -52,11 +79,28 @@ export function createDashboardTileCache(): DashboardTileCache { async resolve(key, ttlMs, now, load) { const cached = entries.get(key); if (cached && cached.expiresAt > now) { - return cached.response; + return { + cache: "hit", + coalesced: false, + response: cached.response, + }; } const active = inFlight.get(key); - if (active) return active; + 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) => { @@ -74,7 +118,11 @@ export function createDashboardTileCache(): DashboardTileCache { }); inFlight.set(key, request); - return request; + return request.then((response) => ({ + cache: "miss" as const, + coalesced: false, + response, + })); }, }; } @@ -125,13 +173,26 @@ export async function loadDashboardTileResponse( options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1" ) { - return { + 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, @@ -139,22 +200,61 @@ export async function loadDashboardTileResponse( }); if (dashboard.state !== "ready") { - return { + 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(); - return cache.resolve( - dashboardTileCacheKey(tile), - dashboardTileTtlMs(dashboard.document, tile), - now, - () => resolveDashboardTile(dashboard.document, tile, options), - ); + 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), + }; } export async function handleDashboardRoute(): Promise { @@ -180,10 +280,278 @@ export async function handleDashboardTileRoute( }); } +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, diff --git a/apps/web/tests/e2e/dashboard.spec.ts b/apps/web/tests/e2e/dashboard.spec.ts index 48eac8b..3eb0944 100644 --- a/apps/web/tests/e2e/dashboard.spec.ts +++ b/apps/web/tests/e2e/dashboard.spec.ts @@ -16,7 +16,6 @@ const linkedServiceIds = [ "open-webui", "comfyui", "models", - "prompt-registry", "adminer", "assistant", "suna", @@ -104,7 +103,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(28); + expect(metrics.serviceRowCount).toBe(27); 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 c548a8a..a1c0ada 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 a007213..f5d5163 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 0542e73..48293a4 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/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh new file mode 100755 index 0000000..4fac4cd --- /dev/null +++ b/scripts/deploy-dimensionlab-website.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_NAME="${APP_NAME:-dimensionlab-website}" +SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}" +CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}" +IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}" +CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}" +PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}" +TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}" +DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}" +DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}" +DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}" + +dry_run=false +rollback_tag="" +release_tag="" +latest_tag="${IMAGE_REPO}:latest" +container_cli="" +deployment_started=false +rollback_done=false +rollback_in_progress=false + +usage() { + cat <&2 + if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then + rollback || true + fi + exit 1 +} + +run() { + if "$dry_run"; then + printf '[deploy:%s] DRY-RUN:' "$APP_NAME" + printf ' %q' "$@" + printf '\n' + return 0 + fi + + "$@" +} + +for arg in "$@"; do + case "$arg" in + --dry-run) + dry_run=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown argument: $arg" + ;; + esac +done + +deployment_ref() { + printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}" +} + +deployment_event() { + printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}" +} + +require_main_push() { + local event + local ref + + event="$(deployment_event)" + ref="$(deployment_ref)" + + if [ -n "$event" ] && [ "$event" != "push" ]; then + fail "refusing to deploy for event '$event'; production deploys only run for push" + fi + + if [ -n "$ref" ]; then + [ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main" + return 0 + fi + + local branch + branch="$(git branch --show-current 2>/dev/null || true)" + [ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main" +} + +select_container_cli() { + if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then + command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI" + container_cli="$DEPLOY_CONTAINER_CLI" + return 0 + fi + + if command -v podman >/dev/null 2>&1; then + container_cli="podman" + return 0 + fi + + if command -v docker >/dev/null 2>&1; then + container_cli="docker" + return 0 + fi + + fail "podman or docker is required" +} + +current_sha() { + if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then + printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}" + return 0 + fi + + git rev-parse HEAD +} + +tag_existing_latest_for_rollback() { + rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)" + if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then + log "tagging current latest image as $rollback_tag" + run "$container_cli" tag "$latest_tag" "$rollback_tag" + else + log "no existing $latest_tag image found; rollback image tag will not be created" + rollback_tag="" + fi +} + +container_systemd_unit() { + "$container_cli" inspect "$CONTAINER_NAME" \ + --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true +} + +require_container_managed_by_service() { + local unit + + if "$dry_run"; then + log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME" + return 0 + fi + + unit="$(container_systemd_unit)" + [ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'" +} + +validate_restart_strategy() { + case "$DEPLOY_RESTART_STRATEGY" in + systemctl) + if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then + fail "systemctl --user cannot access $SERVICE_NAME" + fi + ;; + quadlet-container|kill-container) + require_container_managed_by_service + ;; + auto) + if "$dry_run"; then + log "DRY-RUN: would validate automatic restart strategy" + elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then + require_container_managed_by_service + fi + ;; + *) + fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" + ;; + esac +} + +initialize_submodules() { + log "initializing submodules" + run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + run git submodule update --init --recursive +} + +build_image() { + local sha + local short_sha + + sha="$(current_sha)" + short_sha="${sha:0:12}" + release_tag="${IMAGE_REPO}:${short_sha}" + + [ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE" + + log "building $release_tag and $latest_tag from $CONTAINERFILE" + run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" . +} + +restart_service() { + log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY" + + case "$DEPLOY_RESTART_STRATEGY" in + systemctl) + run systemctl --user restart "$SERVICE_NAME" + ;; + quadlet-container|kill-container) + require_container_managed_by_service + run "$container_cli" stop "$CONTAINER_NAME" + ;; + auto) + if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then + run systemctl --user restart "$SERVICE_NAME" + else + require_container_managed_by_service + run "$container_cli" stop "$CONTAINER_NAME" + fi + ;; + *) + fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" + ;; + esac +} + +latest_image_id() { + "$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true +} + +container_image_id() { + "$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true +} + +container_running() { + local running + running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)" + [ "$running" = "true" ] +} + +wait_for_container_restart() { + local expected_image + + if "$dry_run"; then + log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag" + return 0 + fi + + expected_image="$(latest_image_id)" + [ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag" + + wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" +} + +wait_for_container_image() { + local expected_image="$1" + local label="$2" + local deadline + + deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS)) + + while [ "$SECONDS" -lt "$deadline" ]; do + if container_running && [ "$(container_image_id)" = "$expected_image" ]; then + log "$CONTAINER_NAME is running the $label" + return 0 + fi + + sleep 2 + done + + return 1 +} + +smoke_get() { + local url="$1" + curl -fsS --max-time 10 -o /dev/null "$url" +} + +smoke_tiles() { + local response + + response="$( + curl -fsS --max-time 20 \ + -H "content-type: application/json" \ + --data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \ + "$TILE_BATCH_URL" + )" + + [[ "$response" == *'"state":"ready"'* ]] +} + +wait_for_smoke() { + local deadline + + if "$dry_run"; then + log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL" + return 0 + fi + + deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS)) + until smoke_get "$PUBLIC_URL" && smoke_tiles; do + if [ "$SECONDS" -ge "$deadline" ]; then + fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL" + fi + + sleep 3 + done + + log "smoke checks passed" +} + +rollback() { + local rollback_image + + if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then + return 0 + fi + + rollback_done=true + rollback_in_progress=true + printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2 + rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)" + "$container_cli" tag "$rollback_tag" "$latest_tag" || true + if container_running; then + restart_service || true + else + printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2 + fi + if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then + printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2 + else + printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2 + fi + rollback_in_progress=false +} + +on_error() { + local status=$? + rollback + exit "$status" +} + +trap on_error ERR + +require_main_push +select_container_cli +validate_restart_strategy + +log "using container CLI: $container_cli" +log "target image: $latest_tag" +log "target service: $SERVICE_NAME" + +initialize_submodules +tag_existing_latest_for_rollback +build_image +deployment_started=true +restart_service +wait_for_container_restart +wait_for_smoke + +log "deployment finished"