diff --git a/.containerignore b/.containerignore index 1812091..70b617e 100644 --- a/.containerignore +++ b/.containerignore @@ -5,18 +5,8 @@ coverage data dist node_modules -out playwright-report storybook-static test-results .env .env.* -apps/*/.turbo -apps/*/build -apps/*/data -apps/*/dist -apps/*/playwright-report -apps/*/test-results -packages/*/.turbo -packages/*/dist -packages/*/storybook-static 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/.gitignore b/.gitignore index eed0de1..b6326bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -out/ .svelte-kit/ build/ dist/ 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..59dedf6 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,20 @@ reusable React component library. This project is not a Homepage customization and does not depend on Homepage runtime, frontend code, or configuration. The dashboard will be model-driven: -the reusable UI package stays content-free, the reusable dashboard model -package owns schema and validation, and environment-specific data lives in -validated dashboard model state inside the web app. +the reusable UI package stays content-free, while environment-specific data +lives in validated dashboard model state inside the web app. ## Workspace Layout - `apps/web`: Vite React website, Bun API server, model fixtures, Drizzle 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. - `docs/superpowers`: migration specs and execution plans used for this repo. ## Development ```sh -git submodule update --init --recursive bun install bun run dev ``` @@ -38,8 +32,7 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence. - `bun run test`: run the unit test stage in all workspaces. - `bun run test:unit`: run Vitest explicitly as the unit test stage. - `bun run test:e2e`: build and run Playwright browser smoke and QA checks. -- `bun run test:qa`: run the release gate through Turbo across check, unit, - build, Storybook, and e2e tasks. +- `bun run test:qa`: run the release gate through Turbo. - `bun run build`: build the UI package, production website, and Bun server. - `bun run preview`: preview the production web build. - `bun run storybook`: start the UI package component explorer on port 6006. @@ -71,7 +64,7 @@ with an explicit migration error. ## Seed Data The initial Dimension Lab dashboard lives in -`apps/web/src/lib/dashboard-seed/dimensionlab.ts` as validated model data. It +`apps/web/src/lib/model/fixtures/dimensionlab.ts` as validated model data. It includes the first-screen telemetry, service groups, status strip, weather module, Iconify icon identifiers, links, and datasource references. Values that are not live yet are labeled as fallback values in the data so later datasource @@ -109,9 +102,7 @@ bun run test:qa The gate runs TypeScript checks, Vitest coverage for model, persistence, renderer, datasource mocks, and presentation boundaries, the production build, the static Storybook build, and Playwright desktop/mobile -smoke checks against the built adapter output. Turbo owns the release task -graph; app package scripts stay as leaf commands and do not re-run the QA -pipeline internally. Playwright also performs +smoke checks against the built adapter output. Playwright also performs baseline screenshot checks, keyboard navigation checks, reduced-motion checks, landmark checks, and axe accessibility checks against the real model-driven route. @@ -129,7 +120,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 @@ -142,11 +132,10 @@ the checked-in `apps/web/drizzle/` directory so startup migrations can run. ### Internal Container -The checked-in `apps/web/Containerfile` runs -`turbo prune @dimensionlab/web --docker`, installs the pruned manifest set, and -builds the React client plus Bun server from the pruned workspace source. For the -Dimension Lab internal host, run it behind Caddy on a loopback port and mount -persistent state at `/data`: +The checked-in `apps/web/Containerfile` builds the React client and Bun server +from the workspace root into a runtime image. For the Dimension Lab internal +host, run it behind Caddy on a loopback port and mount persistent state at +`/data`: ```sh podman build -f apps/web/Containerfile -t localhost/dimensionlab-website:latest . @@ -161,27 +150,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..912f780 100644 --- a/apps/web/Containerfile +++ b/apps/web/Containerfile @@ -1,25 +1,18 @@ -FROM docker.io/oven/bun:1.3.14 AS base +FROM docker.io/oven/bun:1.3.14 AS deps WORKDIR /repo -ENV PATH=/repo/apps/web/node_modules/.bin:/repo/packages/dashboard-model/node_modules/.bin:/repo/packages/ui/node_modules/.bin:/repo/node_modules/.bin:$PATH - -FROM base AS pruner - -COPY . . -RUN bunx turbo prune @dimensionlab/web --docker - -FROM base AS deps - -COPY --from=pruner /repo/out/json/ ./ -RUN bun install --frozen-lockfile --ignore-scripts +ENV PATH=/repo/apps/web/node_modules/.bin:/repo/packages/ui/node_modules/.bin:/repo/node_modules/.bin:$PATH +COPY package.json bun.lock turbo.json tsconfig.base.json ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN bun install --frozen-lockfile FROM deps AS build -COPY --from=pruner /repo/out/full/ ./ -COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./ -RUN bun run build +COPY . . +RUN bun install --frozen-lockfile && bun run build -FROM base AS runtime +FROM deps AS runtime WORKDIR /repo/apps/web ENV NODE_ENV=production diff --git a/apps/web/package.json b/apps/web/package.json index 015fc55..7875aa8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,17 +5,17 @@ "type": "module", "scripts": { "dev": "bun src/server/dev.ts", - "build": "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", + "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", + "test:qa": "bun run check && bun run test:unit && bun run build && bun run test:e2e", "db:generate": "drizzle-kit generate", "db:check": "drizzle-kit check" }, "dependencies": { - "@dimensionlab/dashboard-model": "workspace:*", "@dimensionlab/ui": "workspace:*", "@sinclair/typebox": "^0.34.49", "ajv": "^8.20.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index ac0fe5e..afba557 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -21,13 +21,13 @@ export default defineConfig({ }, webServer: [ { - command: `DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`, + command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`, url: baseURL, reuseExistingServer: false, timeout: 120_000, }, { - command: `cd ../.. && STORYBOOK_STATIC_PORT=${storybookPort} bun apps/web/tests/e2e/storybook-server.ts`, + command: `cd ../.. && bun run build-storybook && STORYBOOK_STATIC_PORT=${storybookPort} bun apps/web/tests/e2e/storybook-server.ts`, url: storybookURL, reuseExistingServer: false, timeout: 120_000, 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/model/fixtures/dimensionlab.test.ts similarity index 99% rename from apps/web/src/lib/dashboard-seed/dimensionlab.test.ts rename to apps/web/src/lib/model/fixtures/dimensionlab.test.ts index 1a97c3b..38b2ac6 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts +++ b/apps/web/src/lib/model/fixtures/dimensionlab.test.ts @@ -7,7 +7,7 @@ import type { DatasourceReference, ServiceEntry, TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "../schema"; describe("Dimension Lab dashboard seed", () => { test("defines the primary first-screen sections as model data", () => { @@ -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/model/fixtures/dimensionlab.ts similarity index 98% rename from apps/web/src/lib/dashboard-seed/dimensionlab.ts rename to apps/web/src/lib/model/fixtures/dimensionlab.ts index 59162e6..c2b4808 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.ts +++ b/apps/web/src/lib/model/fixtures/dimensionlab.ts @@ -7,7 +7,7 @@ import { type ServiceGroup, type Severity, type TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "../schema"; type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number"; @@ -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/packages/dashboard-model/src/fixtures/generic.ts b/apps/web/src/lib/model/fixtures/generic.ts similarity index 100% rename from packages/dashboard-model/src/fixtures/generic.ts rename to apps/web/src/lib/model/fixtures/generic.ts diff --git a/apps/web/src/lib/model/fixtures/index.ts b/apps/web/src/lib/model/fixtures/index.ts new file mode 100644 index 0000000..a04278d --- /dev/null +++ b/apps/web/src/lib/model/fixtures/index.ts @@ -0,0 +1,2 @@ +export { dimensionLabDashboardFixture } from "./dimensionlab"; +export { genericDashboardFixture } from "./generic"; diff --git a/packages/dashboard-model/src/index.ts b/apps/web/src/lib/model/index.ts similarity index 100% rename from packages/dashboard-model/src/index.ts rename to apps/web/src/lib/model/index.ts diff --git a/packages/dashboard-model/src/schema.test.ts b/apps/web/src/lib/model/schema.test.ts similarity index 96% rename from packages/dashboard-model/src/schema.test.ts rename to apps/web/src/lib/model/schema.test.ts index 9975f87..7eeed74 100644 --- a/packages/dashboard-model/src/schema.test.ts +++ b/apps/web/src/lib/model/schema.test.ts @@ -5,6 +5,7 @@ import { validateDashboardDocument, } from "."; import { + dimensionLabDashboardFixture, genericDashboardFixture, } from "./fixtures"; @@ -18,6 +19,12 @@ describe("dashboard model validation", () => { } }); + it("accepts the Dimension Lab dashboard fixture", () => { + const result = validateDashboardDocument(dimensionLabDashboardFixture); + + expect(result.valid).toBe(true); + }); + it("rejects documents with an unsupported schema version", () => { const invalid = { ...genericDashboardFixture, diff --git a/packages/dashboard-model/src/schema.ts b/apps/web/src/lib/model/schema.ts similarity index 100% rename from packages/dashboard-model/src/schema.ts rename to apps/web/src/lib/model/schema.ts diff --git a/packages/dashboard-model/src/validation.ts b/apps/web/src/lib/model/validation.ts similarity index 100% rename from packages/dashboard-model/src/validation.ts rename to apps/web/src/lib/model/validation.ts diff --git a/apps/web/src/lib/presentation-boundary.test.ts b/apps/web/src/lib/presentation-boundary.test.ts index 8a62205..7daf204 100644 --- a/apps/web/src/lib/presentation-boundary.test.ts +++ b/apps/web/src/lib/presentation-boundary.test.ts @@ -74,7 +74,5 @@ function findFiles(path: string, extension: string): string[] { } function withoutInternalPackageScope(source: string): string { - return source - .replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model") - .replaceAll("@dimensionlab/ui", "@internal/ui"); + return source.replaceAll("@dimensionlab/ui", "@internal/ui"); } diff --git a/apps/web/src/lib/server/agent-config/agent-config.test.ts b/apps/web/src/lib/server/agent-config/agent-config.test.ts index cdc60cf..87449e8 100644 --- a/apps/web/src/lib/server/agent-config/agent-config.test.ts +++ b/apps/web/src/lib/server/agent-config/agent-config.test.ts @@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import type { DashboardDocument } from "$lib/model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store"; import { AgentConfigAuthorizationError, diff --git a/apps/web/src/lib/server/agent-config/index.ts b/apps/web/src/lib/server/agent-config/index.ts index ab3d0ae..ffa7dd8 100644 --- a/apps/web/src/lib/server/agent-config/index.ts +++ b/apps/web/src/lib/server/agent-config/index.ts @@ -16,7 +16,7 @@ import { type ServiceGroup, type StatusItem, type TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; import { createDashboardStore, DashboardRevisionNotFoundError, diff --git a/apps/web/src/lib/server/dashboard.test.ts b/apps/web/src/lib/server/dashboard.test.ts index 5d93422..b15fa6c 100644 --- a/apps/web/src/lib/server/dashboard.test.ts +++ b/apps/web/src/lib/server/dashboard.test.ts @@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { createDashboardStore, type DashboardStore } from "./db/dashboard-store"; import { loadDashboardRuntime } from "./dashboard"; @@ -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..7821ee0 100644 --- a/apps/web/src/lib/server/dashboard.ts +++ b/apps/web/src/lib/server/dashboard.ts @@ -1,5 +1,5 @@ -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import type { DashboardDocument } from "$lib/model"; import { createDashboardStore, DashboardPersistenceValidationError, @@ -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..19082a2 100644 --- a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts +++ b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test, vi } from "vitest"; import { DASHBOARD_SCHEMA_VERSION, type DashboardDocument, -} from "@dimensionlab/dashboard-model"; -import { resolveDashboardDatasources, resolveDashboardTile } from "."; +} from "$lib/model"; +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..804fbb5 100644 --- a/apps/web/src/lib/server/datasources/index.ts +++ b/apps/web/src/lib/server/datasources/index.ts @@ -8,40 +8,10 @@ import type { StatusItem, StatusStrip, 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; - }; +} from "$lib/model"; 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/server/db/dashboard-store.test.ts b/apps/web/src/lib/server/db/dashboard-store.test.ts index c2717f5..ecb80f3 100644 --- a/apps/web/src/lib/server/db/dashboard-store.test.ts +++ b/apps/web/src/lib/server/db/dashboard-store.test.ts @@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import type { DashboardDocument } from "$lib/model"; import { DashboardPersistenceValidationError, createDashboardStore, diff --git a/apps/web/src/lib/server/db/dashboard-store.ts b/apps/web/src/lib/server/db/dashboard-store.ts index 3928206..92c7a73 100644 --- a/apps/web/src/lib/server/db/dashboard-store.ts +++ b/apps/web/src/lib/server/db/dashboard-store.ts @@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm"; import { type DashboardDocument, type DashboardValidationFailure, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; import { type DashboardDatabaseConnection, openDashboardDatabase, diff --git a/apps/web/src/lib/server/db/model-migrations.test.ts b/apps/web/src/lib/server/db/model-migrations.test.ts index 5c863a0..eb16b8f 100644 --- a/apps/web/src/lib/server/db/model-migrations.test.ts +++ b/apps/web/src/lib/server/db/model-migrations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { DASHBOARD_SCHEMA_VERSION } from "@dimensionlab/dashboard-model"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { DASHBOARD_SCHEMA_VERSION } from "$lib/model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { UnsupportedDashboardModelVersionError, migrateDashboardDocumentForPersistence, diff --git a/apps/web/src/lib/server/db/model-migrations.ts b/apps/web/src/lib/server/db/model-migrations.ts index 72e9c47..92550e6 100644 --- a/apps/web/src/lib/server/db/model-migrations.ts +++ b/apps/web/src/lib/server/db/model-migrations.ts @@ -3,7 +3,7 @@ import { validateDashboardDocument, type DashboardDocument, type DashboardValidationFailure, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; export interface DashboardModelMigrationSuccess { valid: true; diff --git a/apps/web/src/lib/server/db/schema.ts b/apps/web/src/lib/server/db/schema.ts index a3a7206..c148035 100644 --- a/apps/web/src/lib/server/db/schema.ts +++ b/apps/web/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import type { DashboardDocument } from "$lib/model"; import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const dashboardDocuments = sqliteTable("dashboard_documents", { diff --git a/apps/web/src/lib/ui-adapter/model-renderer.test.ts b/apps/web/src/lib/ui-adapter/model-renderer.test.ts index 2f8178d..fb04ec4 100644 --- a/apps/web/src/lib/ui-adapter/model-renderer.test.ts +++ b/apps/web/src/lib/ui-adapter/model-renderer.test.ts @@ -1,9 +1,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { type DashboardDocument } from "@dimensionlab/dashboard-model"; -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { type DashboardDocument } from "$lib/model"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { dashboardDocumentToUiDashboard } from "./model-renderer"; const appRoot = process.cwd().endsWith(`${join("apps", "web")}`) @@ -93,7 +93,6 @@ describe("dashboard model renderer", () => { "utf8", ) .toLowerCase() - .replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model") .replaceAll("@dimensionlab/ui", "@internal/ui"); expect(source).not.toContain("dimension"); diff --git a/apps/web/src/lib/ui-adapter/model-renderer.ts b/apps/web/src/lib/ui-adapter/model-renderer.ts index 7ac1912..1db2173 100644 --- a/apps/web/src/lib/ui-adapter/model-renderer.ts +++ b/apps/web/src/lib/ui-adapter/model-renderer.ts @@ -6,7 +6,7 @@ import type { StatusItem, StatusStrip, TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; import type { UiDashboardPreview, UiModuleBlock, diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index 58cf203..66a11c5 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"; @@ -20,79 +18,10 @@ describe("workspace boundaries", () => { expect(packageJson.private).toBe(true); expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); - expect(packageJson.scripts?.build).toBe("turbo run build"); + expect(packageJson.scripts?.build).toBe("turbo build"); expect(existsSync(join(root, "turbo.json"))).toBe(true); }); - test("keeps release orchestration in the root turbo task graph", () => { - const rootPackage = JSON.parse( - readFileSync(join(root, "package.json"), "utf8"), - ) as { - scripts?: Record; - }; - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { scripts?: Record }; - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { - globalDependencies?: string[]; - tasks?: Record; - }; - - expect(rootPackage.scripts?.["test:qa"]).toBe( - "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"]), - ); - expect(turboConfig.tasks?.["test:e2e"]?.env).toEqual( - expect.arrayContaining([ - "CI", - "PLAYWRIGHT_DATABASE_URL", - "PLAYWRIGHT_PORT", - "PLAYWRIGHT_STORYBOOK_PORT", - ]), - ); - }); - - test("lets turbo build app and Storybook artifacts before e2e serves them", () => { - const playwrightConfig = readFileSync( - join(root, "apps/web/playwright.config.ts"), - "utf8", - ); - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { - tasks?: Record; - }; - - expect(playwrightConfig).not.toContain("bun run build &&"); - expect(playwrightConfig).not.toContain("bun run build-storybook"); - expect(turboConfig.tasks?.["test:e2e"]?.dependsOn).toEqual( - expect.arrayContaining(["@dimensionlab/ui#build-storybook"]), - ); - }); - - test("accounts for local env files in cacheable Vite and Storybook task hashes", () => { - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { - tasks?: Record; - }; - - for (const taskName of ["build", "build-storybook"]) { - expect(turboConfig.tasks?.[taskName]?.inputs).toEqual([ - "$TURBO_DEFAULT$", - ".env*", - ]); - } - }); - test("keeps the website app and reusable UI library as separate packages", () => { const webPackage = JSON.parse( readFileSync(join(root, "apps/web/package.json"), "utf8"), @@ -107,472 +36,4 @@ describe("workspace boundaries", () => { expect(uiPackage.exports).toHaveProperty("."); expect(uiPackage.exports).toHaveProperty("./styles.css"); }); - - test("keeps the dashboard model in a reusable internal package", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { dependencies?: Record }; - const modelPackage = JSON.parse( - readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"), - ) as { exports?: Record; name?: string }; - const tsconfig = JSON.parse( - readFileSync(join(root, "tsconfig.json"), "utf8"), - ) as { references?: Array<{ path: string }> }; - - expect(webPackage.dependencies?.["@dimensionlab/dashboard-model"]).toBe( - "workspace:*", - ); - expect(modelPackage.name).toBe("@dimensionlab/dashboard-model"); - expect(modelPackage.exports).toHaveProperty("."); - expect(modelPackage.exports).toHaveProperty("./fixtures"); - expect(tsconfig.references).toEqual( - expect.arrayContaining([{ path: "./packages/dashboard-model" }]), - ); - expect(existsSync(join(root, "apps/web/src/lib/model/index.ts"))).toBe( - false, - ); - }); - - test("consumes workspace packages through package exports instead of source aliases", () => { - const webTsconfig = JSON.parse( - readFileSync(join(root, "apps/web/tsconfig.json"), "utf8"), - ) as { compilerOptions?: { paths?: Record } }; - const modelPackage = JSON.parse( - readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"), - ) as { exports?: Record }; - const uiPackage = JSON.parse( - readFileSync(join(root, "packages/ui/package.json"), "utf8"), - ) as { exports?: Record }; - const viteConfig = readFileSync(join(root, "apps/web/vite.config.ts"), "utf8"); - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { tasks?: Record }; - - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty( - "@dimensionlab/dashboard-model", - ); - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty( - "@dimensionlab/dashboard-model/fixtures", - ); - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty("@dimensionlab/ui"); - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty( - "@dimensionlab/ui/styles.css", - ); - expect(webTsconfig.compilerOptions?.paths).toEqual({ - "$lib/*": ["src/lib/*"], - }); - expect(viteConfig).not.toContain("../../packages/dashboard-model/src"); - expect(viteConfig).not.toContain("../../packages/ui/src"); - expect(turboConfig.tasks?.dev?.dependsOn).toEqual(["^build"]); - expectPackageExport(modelPackage.exports?.["."], { - types: "./dist/index.d.ts", - development: "./src/index.ts", - default: "./dist/index.js", - }); - expectPackageExport(modelPackage.exports?.["./fixtures"], { - types: "./dist/fixtures/index.d.ts", - development: "./src/fixtures/index.ts", - default: "./dist/fixtures/index.js", - }); - expectPackageExport(uiPackage.exports?.["."], { - types: "./dist/index.d.ts", - development: "./src/index.ts", - default: "./dist/index.js", - }); - expectPackageExport(uiPackage.exports?.["./styles.css"], { - development: "./src/styles.css", - default: "./dist/styles.css", - }); - expectPackageExport(uiPackage.exports?.["./tokens.css"], { - development: "./src/tokens.css", - default: "./dist/tokens.css", - }); - }); - - test("builds the internal container from a turbo-pruned web workspace", () => { - const containerfile = readFileSync( - join(root, "apps/web/Containerfile"), - "utf8", - ); - const pruneCommand = "turbo prune @dimensionlab/web --docker"; - const jsonCopy = "COPY --from=pruner /repo/out/json/ ./"; - 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"; - - expect(containerfile).toContain("AS pruner"); - expect(containerfile).toContain(pruneCommand); - expect(containerfile).toContain(jsonCopy); - expect(containerfile).toContain(sourceCopy); - expect(containerfile).toContain(tsconfigCopy); - expect(containerfile).not.toContain( - "COPY packages/dashboard-model/package.json", - ); - expect(containerfile).not.toContain("COPY packages/ui/package.json"); - - const jsonCopyIndex = containerfile.indexOf(jsonCopy); - const installIndex = containerfile.indexOf(installCommand); - const sourceCopyIndex = containerfile.indexOf(sourceCopy); - const tsconfigCopyIndex = containerfile.indexOf(tsconfigCopy); - - expect(jsonCopyIndex).toBeGreaterThan(-1); - expect(installIndex).toBeGreaterThan(jsonCopyIndex); - expect(sourceCopyIndex).toBeGreaterThan(installIndex); - expect(tsconfigCopyIndex).toBeGreaterThan(sourceCopyIndex); - expect(containerfile.indexOf("RUN bun run build")).toBeGreaterThan( - tsconfigCopyIndex, - ); - }); - - test("keeps local turbo prune output out of git and container contexts", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { scripts?: Record }; - const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); - const containerignore = readFileSync(join(root, ".containerignore"), "utf8"); - const containerIgnoreRules = new Set( - containerignore - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ); - - expect(gitignore).toContain("out/"); - expect(containerignore).toContain("out"); - expect([...containerIgnoreRules]).toEqual( - expect.arrayContaining([ - "apps/*/.turbo", - "apps/*/build", - "apps/*/data", - "apps/*/dist", - "apps/*/playwright-report", - "apps/*/test-results", - "packages/*/.turbo", - "packages/*/dist", - "packages/*/storybook-static", - ]), - ); - expect(webPackage.scripts?.build).toBe( - "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 = - | string - | { - types?: string; - development?: string; - default?: string; - }; - -function expectPackageExport( - actual: WorkspacePackageExport | undefined, - expected: Exclude, -): 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/page.test.tsx b/apps/web/src/page.test.tsx index 2ec5869..fc68147 100644 --- a/apps/web/src/page.test.tsx +++ b/apps/web/src/page.test.tsx @@ -1,6 +1,6 @@ import { renderToString } from "react-dom/server"; import { describe, expect, test } from "vitest"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { AppStateView, resolveDocumentMetadata } from "./App"; describe("home page model renderer", () => { diff --git a/apps/web/src/server/dev.test.ts b/apps/web/src/server/dev.test.ts index 60031db..f43f953 100644 --- a/apps/web/src/server/dev.test.ts +++ b/apps/web/src/server/dev.test.ts @@ -2,7 +2,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { createDevServerConfig } from "../../vite.config"; -import { apiServerArgs } from "./dev"; describe("local development runtime", () => { test("starts the Bun API server together with the Vite dev server", () => { @@ -23,11 +22,4 @@ describe("local development runtime", () => { changeOrigin: true, }); }); - - test("uses development package export conditions for the Bun API server", () => { - expect(apiServerArgs).toEqual([ - "--conditions=development", - "src/server/index.ts", - ]); - }); }); diff --git a/apps/web/src/server/dev.ts b/apps/web/src/server/dev.ts index fa4dc2f..c515240 100644 --- a/apps/web/src/server/dev.ts +++ b/apps/web/src/server/dev.ts @@ -3,10 +3,6 @@ const webPort = process.env.PORT || "5173"; const apiHost = process.env.DASHBOARD_DEV_API_HOST || "127.0.0.1"; const apiPort = process.env.DASHBOARD_DEV_API_PORT || "5174"; const apiTarget = `http://${apiHost}:${apiPort}`; -export const apiServerArgs = [ - "--conditions=development", - "src/server/index.ts", -] as const; if (import.meta.main) { runDevServers(); @@ -55,7 +51,7 @@ export function runDevServers(): void { process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); - spawn("api server", [process.execPath, ...apiServerArgs], { + spawn("api server", [process.execPath, "src/server/index.ts"], { HOST: apiHost, PORT: apiPort, }); 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..ebe575b 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 { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { - createDashboardTileCache, - dashboardTileCacheKey, - handleDashboardEventsRoute, - handleDashboardTilesRoute, - handleDashboardTileRoute, - loadDashboardResponse, - loadDashboardTilesResponse, - loadDashboardTileResponse, - type DashboardTileResolutionLogEvent, -} from "./dashboard"; +import { describe, expect, test } from "vitest"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +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/apps/web/tsconfig.json b/apps/web/tsconfig.json index 120931b..edfa07d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { + "@dimensionlab/ui": ["../../packages/ui/src/index.ts"], + "@dimensionlab/ui/styles.css": ["../../packages/ui/src/styles.css"], "$lib/*": ["src/lib/*"] }, "types": ["node", "bun-types", "react", "react-dom", "vite/client"] diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ceff746..d88ee57 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -24,6 +24,12 @@ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { + "@dimensionlab/ui/styles.css": fileURLToPath( + new URL("../../packages/ui/src/styles.css", import.meta.url), + ), + "@dimensionlab/ui": fileURLToPath( + new URL("../../packages/ui/src/index.ts", import.meta.url), + ), $lib: fileURLToPath(new URL("./src/lib", import.meta.url)), }, }, diff --git a/bun.lock b/bun.lock index d6915d0..543520d 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "name": "@dimensionlab/web", "version": "0.0.1", "dependencies": { - "@dimensionlab/dashboard-model": "workspace:*", "@dimensionlab/ui": "workspace:*", "@sinclair/typebox": "^0.34.49", "ajv": "^8.20.0", @@ -41,22 +40,6 @@ "vitest": "^4.1.9", }, }, - "packages/dashboard-model": { - "name": "@dimensionlab/dashboard-model", - "version": "0.0.1", - "dependencies": { - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - }, - "devDependencies": { - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "bun-types": "^1.3.14", - "typescript": "^6.0.3", - "vitest": "^4.1.9", - }, - }, "packages/ui": { "name": "@dimensionlab/ui", "version": "0.0.1", @@ -154,8 +137,6 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@dimensionlab/dashboard-model": ["@dimensionlab/dashboard-model@workspace:packages/dashboard-model"], - "@dimensionlab/ui": ["@dimensionlab/ui@workspace:packages/ui"], "@dimensionlab/web": ["@dimensionlab/web@workspace:apps/web"], @@ -690,7 +671,7 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -700,7 +681,7 @@ "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], @@ -764,7 +745,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -892,7 +873,7 @@ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -1498,8 +1479,6 @@ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -1516,6 +1495,14 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1562,14 +1549,14 @@ "shadcn/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], - - "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1638,6 +1625,8 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1700,16 +1689,10 @@ "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - - "storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], - - "storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index e33d3aa..5b94c8f 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,18 @@ "packages/*" ], "scripts": { - "dev": "turbo run dev --filter=@dimensionlab/web", - "build": "turbo run build", - "preview": "turbo run preview --filter=@dimensionlab/web", - "storybook": "turbo run storybook --filter=@dimensionlab/ui", - "build-storybook": "turbo run build-storybook --filter=@dimensionlab/ui", - "check": "turbo run check", - "test": "turbo run test:unit", - "test:unit": "turbo run test:unit", - "test:e2e": "turbo run test:e2e --filter=@dimensionlab/web", - "test:qa": "turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e", - "db:generate": "turbo run db:generate --filter=@dimensionlab/web", - "db:check": "turbo run db:check --filter=@dimensionlab/web" + "dev": "turbo dev --filter=@dimensionlab/web", + "build": "turbo build", + "preview": "bun run --cwd apps/web preview", + "storybook": "turbo storybook --filter=@dimensionlab/ui", + "build-storybook": "turbo build-storybook --filter=@dimensionlab/ui", + "check": "turbo check", + "test": "turbo test:unit", + "test:unit": "turbo test:unit", + "test:e2e": "turbo test:e2e --filter=@dimensionlab/web", + "test:qa": "turbo test:qa", + "db:generate": "bun run --cwd apps/web db:generate", + "db:check": "bun run --cwd apps/web db:check" }, "devDependencies": { "turbo": "^2.5.0" diff --git a/packages/dashboard-model/package.json b/packages/dashboard-model/package.json deleted file mode 100644 index ae9a49b..0000000 --- a/packages/dashboard-model/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@dimensionlab/dashboard-model", - "version": "0.0.1", - "private": true, - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "development": "./src/index.ts", - "default": "./dist/index.js" - }, - "./fixtures": { - "types": "./dist/fixtures/index.d.ts", - "development": "./src/fixtures/index.ts", - "default": "./dist/fixtures/index.js" - } - }, - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "check": "tsc --noEmit", - "test": "vitest run", - "test:unit": "vitest run" - }, - "dependencies": { - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1" - }, - "devDependencies": { - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "bun-types": "^1.3.14", - "typescript": "^6.0.3", - "vitest": "^4.1.9" - } -} diff --git a/packages/dashboard-model/src/fixtures/index.ts b/packages/dashboard-model/src/fixtures/index.ts deleted file mode 100644 index f62d646..0000000 --- a/packages/dashboard-model/src/fixtures/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { genericDashboardFixture } from "./generic"; diff --git a/packages/dashboard-model/tsconfig.build.json b/packages/dashboard-model/tsconfig.build.json deleted file mode 100644 index 5d3e7ce..0000000 --- a/packages/dashboard-model/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": true, - "emitDeclarationOnly": false, - "noEmit": false, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts"] -} diff --git a/packages/dashboard-model/tsconfig.json b/packages/dashboard-model/tsconfig.json deleted file mode 100644 index 26cb1c4..0000000 --- a/packages/dashboard-model/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "types": ["node", "bun-types"] - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] -} 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..cb7febf --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,52 @@ +{ + "name": "@dimensionlab/ui", + "version": "0.0.1", + "private": true, + "type": "module", + "sideEffects": ["*.css", "**/*.css"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./styles.css": "./dist/styles.css", + "./tokens.css": "./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/Badge.tsx b/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..a21ade8 --- /dev/null +++ b/packages/ui/src/components/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/Button.tsx b/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..8ae3b4e --- /dev/null +++ b/packages/ui/src/components/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/CornerBracketFrame.tsx b/packages/ui/src/components/CornerBracketFrame.tsx new file mode 100644 index 0000000..1dfd4f7 --- /dev/null +++ b/packages/ui/src/components/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/DashboardFrame.tsx b/packages/ui/src/components/DashboardFrame.tsx new file mode 100644 index 0000000..cdf312a --- /dev/null +++ b/packages/ui/src/components/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 "./ServicePanel"; +import { StatusStrip } from "./StatusStrip"; +import { TelemetryGrid } from "./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/DashboardHeader.tsx b/packages/ui/src/components/DashboardHeader.tsx new file mode 100644 index 0000000..68b4e11 --- /dev/null +++ b/packages/ui/src/components/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/DiagonalStripeField.tsx b/packages/ui/src/components/DiagonalStripeField.tsx new file mode 100644 index 0000000..b4c60f8 --- /dev/null +++ b/packages/ui/src/components/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 ( +