diff --git a/.containerignore b/.containerignore index 70b617e..1812091 100644 --- a/.containerignore +++ b/.containerignore @@ -5,8 +5,18 @@ 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 new file mode 100644 index 0000000..198a8aa --- /dev/null +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -0,0 +1,85 @@ +name: Dimension Lab website + +on: + pull_request: + types: + - opened + - synchronize + - reopened + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: dimensionlab-website-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + ci: + runs-on: docker + timeout-minutes: 30 + steps: + - name: Checkout + uses: https://data.forgejo.org/actions/checkout@v4 + with: + fetch-depth: 0 + submodules: false + + - name: Initialize submodules + run: | + git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + git submodule update --init --recursive + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14" + "$HOME/.bun/bin/bun" --version + + - name: Check, test, and build + run: | + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" + bun install --frozen-lockfile + bun run check + bun run test + bun run build + + deploy: + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: deploy + timeout-minutes: 30 + steps: + - name: Checkout + run: | + if [ -d .git ]; then + git remote set-url origin git@git.dimensionlab.net:vince/dimensionlab-website.git + else + git init + git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git + fi + git fetch --force --prune --depth=1 origin "$GITHUB_SHA" + git checkout --force --detach "$GITHUB_SHA" + git clean -ffdx + + - name: Initialize submodules + run: | + git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + git submodule update --init --recursive + + - name: Verify Podman deployment socket + run: | + command -v podman + command -v systemctl + unit="$(timeout 15s podman inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')" + test "$unit" = "dimensionlab-website.service" + + - name: Deploy production website + env: + DEPLOY_CONTAINER_CLI: podman + DEPLOY_EVENT_NAME: ${{ github.event_name }} + DEPLOY_REF: ${{ github.ref }} + DEPLOY_RESTART_STRATEGY: quadlet-container + DEPLOY_SHA: ${{ github.sha }} + run: scripts/deploy-dimensionlab-website.sh diff --git a/.gitignore b/.gitignore index 7d8d6c4..eed0de1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ node_modules/ +out/ .svelte-kit/ build/ dist/ .vite/ +.turbo/ +apps/*/.turbo/ +packages/*/.turbo/ .env .env.* @@ -10,6 +14,8 @@ dist/ data/*.sqlite data/*.sqlite-* +apps/*/data/*.sqlite +apps/*/data/*.sqlite-* coverage/ playwright-report/ test-results/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..32d1c14 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "packages/ui"] + path = packages/ui + url = ssh://git@git.dimensionlab.net/vince/dimensionlab-ui.git + branch = main diff --git a/.storybook/main.ts b/.storybook/main.ts deleted file mode 100644 index cfd3a61..0000000 --- a/.storybook/main.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { StorybookConfig } from "@storybook/react-vite"; - -const config: StorybookConfig = { - stories: ["../src/**/*.stories.@(js|ts|tsx)"], - staticDirs: ["../static"], - addons: [ - "@storybook/addon-a11y", - "@storybook/addon-vitest", - ], - framework: { - name: "@storybook/react-vite", - options: {}, - }, - docs: { - autodocs: "tag", - }, -}; - -export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index 59d663d..0000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,35 +0,0 @@ -import "../src/app.css"; -import type { Preview } from "@storybook/react-vite"; -import { setupWorker } from "msw/browser"; -import { externalApiHandlers } from "../src/lib/testing/external-api-mocks"; - -if (typeof window !== "undefined") { - const worker = setupWorker(...externalApiHandlers); - void worker.start({ - onUnhandledRequest: "bypass", - serviceWorker: { - url: "/mockServiceWorker.js", - }, - }); -} - -const preview: Preview = { - parameters: { - backgrounds: { - default: "canvas", - values: [ - { name: "canvas", value: "#020302" }, - { name: "raised", value: "#0b0d0c" }, - ], - }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - layout: "fullscreen", - }, -}; - -export default preview; diff --git a/Containerfile b/Containerfile deleted file mode 100644 index d19c2d0..0000000 --- a/Containerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM docker.io/oven/bun:1.3.14 AS deps - -WORKDIR /app -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile - -FROM deps AS build - -COPY . . -RUN bun run build - -FROM docker.io/oven/bun:1.3.14 AS runtime - -WORKDIR /app -ENV NODE_ENV=production -ENV HOST=0.0.0.0 -ENV PORT=3000 -ENV DATABASE_URL=file:/data/dimensionlab.sqlite -ENV DASHBOARD_MIGRATIONS_DIR=/app/drizzle - -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile --production -COPY --from=build /app/build ./build -COPY --from=build /app/dist ./dist -COPY --from=build /app/drizzle ./drizzle - -RUN mkdir -p /data -VOLUME ["/data"] -EXPOSE 3000 - -CMD ["bun", "build/index.js"] diff --git a/README.md b/README.md index f39dcb5..85177c5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,30 @@ # Dimension Lab Website -Standalone React runtime for the Dimension Lab system overview dashboard. +Turbo/Bun workspace for the Dimension Lab system overview dashboard and its +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 renderer stays content-free, while environment-specific data lives -in validated dashboard model state. +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. + +## 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. +- `docs/superpowers`: migration specs and execution plans used for this repo. ## Development ```sh +git submodule update --init --recursive bun install bun run dev ``` @@ -18,18 +33,19 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence. ## Scripts -- `bun run dev`: start the local Vite development server with a Bun API proxy. -- `bun run check`: run TypeScript checks. -- `bun run test`: run Vitest. +- `bun run dev`: start the web app dev runtime through Turbo. +- `bun run check`: run TypeScript checks in all workspaces. +- `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 MVP release gate. -- `bun run build`: build the production app. -- `bun run preview`: preview the production build. -- `bun run storybook`: start the component explorer on port 6006. -- `bun run build-storybook`: build the static Storybook review artifact. -- `bun run db:generate`: generate Drizzle migrations from the server schema. -- `bun run db:check`: validate migration consistency. +- `bun run test:qa`: run the release gate through Turbo across check, unit, + build, Storybook, and e2e tasks. +- `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. +- `bun run build-storybook`: build the UI package static Storybook artifact. +- `bun run db:generate`: generate web app Drizzle migrations. +- `bun run db:check`: validate web app migration consistency. ## Persistence @@ -40,14 +56,14 @@ URL is: DATABASE_URL=file:./data/dimensionlab.sqlite ``` -SQLite files under `data/` are ignored. Drizzle schema lives in -`src/lib/server/db/schema.ts`; tracked migrations live in `drizzle/`. Runtime -startup applies the checked-in dashboard migrations before reads or writes. If -the app is launched from outside the repo tree, set `DASHBOARD_MIGRATIONS_DIR` -to the tracked migrations directory. The current driver is `bun:sqlite`, which -keeps this repo installable in the Bun workflow. The store boundary is isolated -so a later Postgres driver can replace the SQLite connection without changing -the dashboard model or renderer. +SQLite files under `data/` and `apps/*/data/` are ignored. Drizzle schema lives +in `apps/web/src/lib/server/db/schema.ts`; tracked migrations live in +`apps/web/drizzle/`. Runtime startup applies the checked-in dashboard migrations +before reads or writes. If the app is launched from outside the web app tree, +set `DASHBOARD_MIGRATIONS_DIR` to the tracked migrations directory. The current +driver is `bun:sqlite`, which keeps this repo installable in the Bun workflow. +The store boundary is isolated so a later Postgres driver can replace the +SQLite connection without changing the dashboard model or renderer. Stored dashboard documents pass through a version migration boundary before reads or writes; the MVP supports `dashboard.v1` and fails unsupported versions with an explicit migration error. @@ -55,25 +71,26 @@ with an explicit migration error. ## Seed Data The initial Dimension Lab dashboard lives in -`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 adapters -can replace them without changing presentation components. +`apps/web/src/lib/dashboard-seed/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 +adapters can replace them without changing presentation components. ## Runtime Shape -The browser app is built with Vite and React. Local development starts Vite for -HMR and a loopback Bun API server for `/api/*` routes. Production uses a small -Bun HTTP server at `build/index.js` to serve the Vite `dist/` assets and JSON -API routes. The current persistence runtime is Bun because the MVP SQLite -driver is `bun:sqlite`. +The browser app in `apps/web` is built with Vite and React. Local development +starts Vite for HMR and a loopback Bun API server for `/api/*` routes. +Production uses a small Bun HTTP server at `apps/web/build/index.js` to serve +the Vite `apps/web/dist/` assets and JSON API routes. The current persistence +runtime is Bun because the MVP SQLite driver is `bun:sqlite`. ## Storybook -Storybook covers the reusable UI components with generic fixtures only. Stories -must not import environment-specific dashboard content; the presentation layer -accepts labels, values, icons, status, and links through typed props. +Storybook lives with `packages/ui` and covers the reusable UI components with +generic fixtures only. Stories must not import environment-specific dashboard +content; the presentation layer accepts labels, values, icons, status, and links +through typed props. ## MVP QA Gate @@ -92,7 +109,9 @@ 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. Playwright also performs +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 baseline screenshot checks, keyboard navigation checks, reduced-motion checks, landmark checks, and axe accessibility checks against the real model-driven route. @@ -106,27 +125,31 @@ belong in validated model data, not reusable components. ## Deployment Notes -The production build emits Vite client assets under `dist/` and a Bun server -entry at `build/index.js`. A minimal deployment flow is: +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 DATABASE_URL=file:/data/dimensionlab.sqlite HOST=0.0.0.0 PORT=3000 bun build/index.js ``` Mount `/data` or set `DATABASE_URL` to another persistent SQLite path. If the process starts outside the repository root, set `DASHBOARD_MIGRATIONS_DIR` to -the checked-in `drizzle/` directory so startup migrations can run. +the checked-in `apps/web/drizzle/` directory so startup migrations can run. ### Internal Container -The checked-in `Containerfile` builds the React client and Bun server into a -runtime image. 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` 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`: ```sh -podman build -t localhost/dimensionlab-website:latest . +podman build -f apps/web/Containerfile -t localhost/dimensionlab-website:latest . podman run --rm \ --publish 127.0.0.1:25341:3000 \ --volume "$HOME/containers/dimensionlab-website/data:/data:Z" \ @@ -137,4 +160,28 @@ podman run --rm \ 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=/app/drizzle`. +`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 new file mode 100644 index 0000000..32387a4 --- /dev/null +++ b/apps/web/Containerfile @@ -0,0 +1,39 @@ +FROM docker.io/oven/bun:1.3.14 AS base + +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 + +FROM deps AS build + +COPY --from=pruner /repo/out/full/ ./ +COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./ +RUN bun run build + +FROM base AS runtime + +WORKDIR /repo/apps/web +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV DATABASE_URL=file:/data/dimensionlab.sqlite +ENV DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle + +COPY --from=build /repo/apps/web/build ./build +COPY --from=build /repo/apps/web/dist ./dist +COPY --from=build /repo/apps/web/drizzle ./drizzle + +RUN mkdir -p /data +VOLUME ["/data"] +EXPOSE 3000 + +CMD ["bun", "build/index.js"] diff --git a/drizzle.config.ts b/apps/web/drizzle.config.ts similarity index 100% rename from drizzle.config.ts rename to apps/web/drizzle.config.ts diff --git a/drizzle/0000_dashboard_persistence.sql b/apps/web/drizzle/0000_dashboard_persistence.sql similarity index 100% rename from drizzle/0000_dashboard_persistence.sql rename to apps/web/drizzle/0000_dashboard_persistence.sql diff --git a/drizzle/meta/0000_snapshot.json b/apps/web/drizzle/meta/0000_snapshot.json similarity index 100% rename from drizzle/meta/0000_snapshot.json rename to apps/web/drizzle/meta/0000_snapshot.json diff --git a/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json similarity index 100% rename from drizzle/meta/_journal.json rename to apps/web/drizzle/meta/_journal.json diff --git a/index.html b/apps/web/index.html similarity index 56% rename from index.html rename to apps/web/index.html index 5e47684..75dae71 100644 --- a/index.html +++ b/apps/web/index.html @@ -5,6 +5,15 @@ Dimension Lab +
diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..015fc55 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,51 @@ +{ + "name": "@dimensionlab/web", + "version": "0.0.1", + "private": true, + "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", + "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:e2e": "env -u NO_COLOR playwright test", + "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", + "ajv-formats": "^3.0.1", + "drizzle-orm": "^0.45.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.61.0", + "@tailwindcss/vite": "^4.3.1", + "@types/bun": "^1.3.14", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "bun-types": "^1.3.14", + "drizzle-kit": "^0.31.10", + "msw": "^2.14.6", + "shadcn": "^4.11.0", + "tailwindcss": "^4.3.1", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "msw": { + "workerDirectory": [ + "static" + ] + } +} diff --git a/playwright.config.ts b/apps/web/playwright.config.ts similarity index 60% rename from playwright.config.ts rename to apps/web/playwright.config.ts index 2785c33..ac0fe5e 100644 --- a/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.PLAYWRIGHT_PORT || 4173); const baseURL = `http://127.0.0.1:${port}`; +const storybookPort = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT || 6007); +const storybookURL = `http://127.0.0.1:${storybookPort}`; const databaseUrl = process.env.PLAYWRIGHT_DATABASE_URL || `file:./data/playwright-${process.pid}-${Date.now()}.sqlite`; @@ -17,12 +19,20 @@ export default defineConfig({ trace: "retain-on-failure", screenshot: "only-on-failure", }, - webServer: { - 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, - }, + webServer: [ + { + command: `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`, + url: storybookURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], projects: [ { name: "chromium-desktop", diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 0000000..c1add67 --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,238 @@ +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"; + +describe("React app dashboard state view", () => { + test("renders loading dashboard state", () => { + const html = renderToString( + , + ); + + expect(html).toContain("Loading Dashboard"); + expect(html).toContain("Fetching active model"); + }); + + test("renders an accessible theme toggle with the active theme", () => { + const html = renderToString( + undefined} + />, + ); + + expect(html).toContain('data-ui-theme-toggle="true"'); + expect(html).toContain('data-ui-theme-current="dark"'); + expect(html).toContain('aria-label="Light theme"'); + expect(html).toContain('aria-pressed="false"'); + expect(html).toContain("Theme"); + 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 new file mode 100644 index 0000000..403bef9 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,875 @@ +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, + SystemState, + ThemeToggle, + persistUiTheme, + 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[]; +}; + +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; +}) { + const themeToggle = + theme && onThemeChange ? ( + + ) : null; + + if (dashboard.state === "ready") { + const uiDashboard = markHydratingItems( + dashboardDocumentToUiDashboard(dashboard.document), + hydratingItemIds, + ); + + return ( + + ); + } + + const detail = `${dashboard.subtitle}: ${dashboard.message}`; + const errors = dashboard.state === "invalid" ? dashboard.errors : []; + + return ( +
+ {themeToggle ? ( +
{themeToggle}
+ ) : null} + + {errors.length ? ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null} +
+ ); +} + +export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): { + description: string; + title: string; +} { + if (dashboard.state === "ready") { + const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document); + return { + title: uiDashboard.title, + description: + uiDashboard.subtitle || + dashboard.document.metadata.description || + uiDashboard.title, + }; + } + + return { + title: dashboard.title, + description: dashboard.subtitle || dashboard.message, + }; +} + +export default function App() { + const [dashboard, setDashboard] = + useState(null); + const [hydratingItemIds, setHydratingItemIds] = useState>( + () => new Set(), + ); + const [theme, setTheme] = useState(() => { + if (typeof window === "undefined") return "dark"; + + return resolveInitialUiTheme(getThemeStorage()); + }); + + useEffect(() => { + if (!dashboard) return; + + const metadata = resolveDocumentMetadata(dashboard); + document.title = metadata.title; + + let description = document.querySelector( + 'meta[name="description"]', + ); + if (!description) { + description = document.createElement("meta"); + description.name = "description"; + document.head.append(description); + } + description.content = metadata.description; + }, [dashboard]); + + useEffect(() => { + document.documentElement.dataset.uiTheme = theme; + persistUiTheme(theme, getThemeStorage()); + }, [theme]); + + 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; + + clearRefreshTimer(); + const shellSignal = requestAborter.beginShellRun(); + + 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); + } + } + } + + function hydrateDashboardTiles( + tiles: DashboardTileReference[], + run: number, + signal: AbortSignal, + snapshotContext: DashboardTileSnapshotStoreContext, + ) { + const modelIds = tiles.map(dashboardTileModelId); + + 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), + })), + ); + } + } + + 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(); + }; + }, []); + + return dashboard ? ( + + ) : null; +} + +function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { + if (state === "invalid") return "danger"; + if (state === "loading") return "loading"; + return "stale"; +} + +function stateIcon(state: DashboardRuntimeState["state"]): string { + if (state === "invalid") return "mdi:file-alert-outline"; + if (state === "loading") return "mdi:progress-clock"; + return "mdi:tray"; +} + +function getThemeStorage(): Storage | undefined { + try { + return window.localStorage; + } catch { + 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/src/app.css b/apps/web/src/app.css similarity index 89% rename from src/app.css rename to apps/web/src/app.css index 10a9ee9..eeaabcb 100644 --- a/src/app.css +++ b/apps/web/src/app.css @@ -1,9 +1,7 @@ @import "tailwindcss"; -@import "./lib/ui/tokens.css"; -@import "./lib/ui/components/styles.css"; +@import "@dimensionlab/ui/styles.css"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@import "@fontsource-variable/geist"; @custom-variant dark (&:is(.dark *)); @@ -67,8 +65,8 @@ --accent: var(--ui-color-accent); --accent-foreground: var(--ui-color-canvas); --destructive: var(--ui-color-danger); - --border: rgba(244, 244, 244, 0.16); - --input: rgba(244, 244, 244, 0.18); + --border: var(--ui-color-border); + --input: var(--ui-color-border-strong); --ring: var(--ui-color-accent); --chart-1: var(--ui-color-accent); --chart-2: var(--ui-color-ok); @@ -107,10 +105,11 @@ body { min-height: 100vh; margin: 0; background: - linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + radial-gradient(circle at 50% 12%, transparent 0 42%, var(--ui-color-backdrop-vignette) 100%), + linear-gradient(var(--ui-color-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--ui-color-grid-line) 1px, transparent 1px), var(--ui-color-canvas); - background-size: 48px 48px, 48px 48px, auto; + background-size: auto, 40px 40px, 40px 40px, auto; color: var(--ui-color-text); font-family: var(--ui-font-mono); text-rendering: geometricPrecision; @@ -142,13 +141,17 @@ a { padding: var(--ui-space-4); } +.state-shell__actions { + justify-self: center; +} + .state-shell ul { display: grid; max-width: 56rem; gap: var(--ui-space-2); margin: 0; border: var(--ui-border); - background: rgba(6, 8, 7, 0.82); + background: var(--ui-color-surface-module); color: var(--ui-color-muted); font-size: 0.76rem; list-style-position: inside; diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts new file mode 100644 index 0000000..77ea795 --- /dev/null +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -0,0 +1,633 @@ +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 new file mode 100644 index 0000000..7c50359 --- /dev/null +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -0,0 +1,886 @@ +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/src/lib/model/fixtures/dimensionlab.test.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts similarity index 99% rename from src/lib/model/fixtures/dimensionlab.test.ts rename to apps/web/src/lib/dashboard-seed/dimensionlab.test.ts index 38b2ac6..1a97c3b 100644 --- a/src/lib/model/fixtures/dimensionlab.test.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts @@ -7,7 +7,7 @@ import type { DatasourceReference, ServiceEntry, TelemetryCard, -} from "../schema"; +} from "@dimensionlab/dashboard-model"; describe("Dimension Lab dashboard seed", () => { test("defines the primary first-screen sections as model data", () => { @@ -128,7 +128,6 @@ const verifiedSeedIconIds = new Set([ "mdi:pulse", "mdi:robot-outline", "mdi:router-network", - "mdi:text-box-search", "mdi:thermometer", "mdi:web", "mdi:weather-sunny", diff --git a/src/lib/model/fixtures/dimensionlab.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.ts similarity index 98% rename from src/lib/model/fixtures/dimensionlab.ts rename to apps/web/src/lib/dashboard-seed/dimensionlab.ts index c2b4808..59162e6 100644 --- a/src/lib/model/fixtures/dimensionlab.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.ts @@ -7,7 +7,7 @@ import { type ServiceGroup, type Severity, type TelemetryCard, -} from "../schema"; +} from "@dimensionlab/dashboard-model"; type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number"; @@ -431,14 +431,6 @@ export const dimensionLabDashboardFixture: DashboardDocument = { href: "https://models.dimensionlab.net", datasource: uptimeMonitor(7), }), - service({ - id: "prompt-registry", - label: "Prompt Registry", - description: "Shared prompts, traces, evals", - icon: "mdi:text-box-search", - href: "https://prompts.dimensionlab.net", - datasource: uptimeMonitor(20), - }), ]), group("systems", "Systems", [ service({ diff --git a/src/lib/presentation-boundary.test.ts b/apps/web/src/lib/presentation-boundary.test.ts similarity index 61% rename from src/lib/presentation-boundary.test.ts rename to apps/web/src/lib/presentation-boundary.test.ts index 8e29ece..8a62205 100644 --- a/src/lib/presentation-boundary.test.ts +++ b/apps/web/src/lib/presentation-boundary.test.ts @@ -2,9 +2,17 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +const appRoot = process.cwd().endsWith(`${join("apps", "web")}`) + ? process.cwd() + : join(process.cwd(), "apps", "web"); +const repoRoot = existsSync(join(process.cwd(), "turbo.json")) + ? process.cwd() + : join(appRoot, "..", ".."); const presentationRoots = [ - join(process.cwd(), "src", "lib", "ui"), - join(process.cwd(), "src", "routes"), + join(repoRoot, "packages", "ui", "src"), + join(appRoot, "src", "App.tsx"), + join(appRoot, "src", "app.css"), + join(appRoot, "src", "lib", "ui-adapter"), ]; const forbiddenTerms = [ @@ -26,7 +34,9 @@ const forbiddenTerms = [ describe("presentation content boundary", () => { test("keeps environment-specific content out of route and UI implementation", () => { - const source = presentationRoots.map(readPresentationSource).join("\n").toLowerCase(); + const source = withoutInternalPackageScope( + presentationRoots.map(readPresentationSource).join("\n").toLowerCase(), + ); expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]); }); @@ -34,7 +44,7 @@ describe("presentation content boundary", () => { test("does not keep legacy presentation component files in the React runtime", () => { const legacyExtension = [".sve", "lte"].join(""); - expect(findFiles(join(process.cwd(), "src"), legacyExtension)).toEqual([]); + expect(findFiles(join(appRoot, "src"), legacyExtension)).toEqual([]); }); }); @@ -44,7 +54,9 @@ function readPresentationSource(path: string): string { const stats = statSync(path); if (stats.isFile()) { if (path.endsWith(".test.ts")) return ""; - if (path.includes(`${join("src", "lib", "ui", "stories")}${"/"}`)) return ""; + if (path.includes(`${join("packages", "ui", "src", "stories")}${"/"}`)) { + return ""; + } if (!/\.(tsx|ts|js|mjs|css|json)$/.test(path)) return ""; return readFileSync(path, "utf8"); } @@ -60,3 +72,9 @@ function findFiles(path: string, extension: string): string[] { return readdirSync(path).flatMap((entry) => findFiles(join(path, entry), extension)); } + +function withoutInternalPackageScope(source: string): string { + return source + .replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model") + .replaceAll("@dimensionlab/ui", "@internal/ui"); +} diff --git a/src/lib/server/agent-config/agent-config.test.ts b/apps/web/src/lib/server/agent-config/agent-config.test.ts similarity index 99% rename from src/lib/server/agent-config/agent-config.test.ts rename to apps/web/src/lib/server/agent-config/agent-config.test.ts index 87449e8..cdc60cf 100644 --- a/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 "$lib/model"; -import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store"; import { AgentConfigAuthorizationError, diff --git a/src/lib/server/agent-config/index.ts b/apps/web/src/lib/server/agent-config/index.ts similarity index 99% rename from src/lib/server/agent-config/index.ts rename to apps/web/src/lib/server/agent-config/index.ts index ffa7dd8..ab3d0ae 100644 --- a/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 "$lib/model"; +} from "@dimensionlab/dashboard-model"; import { createDashboardStore, DashboardRevisionNotFoundError, diff --git a/src/lib/server/dashboard.test.ts b/apps/web/src/lib/server/dashboard.test.ts similarity index 89% rename from src/lib/server/dashboard.test.ts rename to apps/web/src/lib/server/dashboard.test.ts index b15fa6c..5d93422 100644 --- a/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/model/fixtures/dimensionlab"; -import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { createDashboardStore, type DashboardStore } from "./db/dashboard-store"; import { loadDashboardRuntime } from "./dashboard"; @@ -66,9 +66,6 @@ describe("dashboard runtime loader", () => { expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain( "auto-refresh", ); - expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain( - "prompt-registry", - ); expect(store.listRevisions()).toHaveLength(2); expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed"); }); @@ -138,13 +135,5 @@ function olderDimensionLabSeed() { ...strip, items: strip.items.filter((item) => item.id !== "auto-refresh"), })); - document.serviceGroups = document.serviceGroups.map((group) => - group.id === "ai-automation" - ? { - ...group, - services: group.services.filter((service) => service.id !== "prompt-registry"), - } - : group, - ); return document; } diff --git a/src/lib/server/dashboard.ts b/apps/web/src/lib/server/dashboard.ts similarity index 94% rename from src/lib/server/dashboard.ts rename to apps/web/src/lib/server/dashboard.ts index 7821ee0..33afa3a 100644 --- a/src/lib/server/dashboard.ts +++ b/apps/web/src/lib/server/dashboard.ts @@ -1,5 +1,5 @@ -import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; -import type { DashboardDocument } from "$lib/model"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import type { DashboardDocument } from "@dimensionlab/dashboard-model"; import { createDashboardStore, DashboardPersistenceValidationError, @@ -18,6 +18,9 @@ export interface DashboardRuntimeReady { document: DashboardDocument; schemaVersion: string; currentRevisionId: string; + liveDatasourceHydration?: { + enabled: boolean; + }; } export interface DashboardRuntimeEmpty { diff --git a/src/lib/server/datasources/dashboard-datasources.test.ts b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts similarity index 73% rename from src/lib/server/datasources/dashboard-datasources.test.ts rename to apps/web/src/lib/server/datasources/dashboard-datasources.test.ts index 19082a2..d24b48b 100644 --- a/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 "$lib/model"; -import { resolveDashboardDatasources } from "."; +} from "@dimensionlab/dashboard-model"; +import { resolveDashboardDatasources, resolveDashboardTile } from "."; describe("dashboard datasource resolution", () => { test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => { @@ -124,6 +124,100 @@ 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/src/lib/server/datasources/index.ts b/apps/web/src/lib/server/datasources/index.ts similarity index 73% rename from src/lib/server/datasources/index.ts rename to apps/web/src/lib/server/datasources/index.ts index 804fbb5..62df13a 100644 --- a/src/lib/server/datasources/index.ts +++ b/apps/web/src/lib/server/datasources/index.ts @@ -8,10 +8,40 @@ import type { StatusItem, StatusStrip, TelemetryCard, -} from "$lib/model"; +} from "@dimensionlab/dashboard-model"; + +export type DashboardTileReference = + | { kind: "telemetry"; id: string } + | { kind: "service"; groupId: string; id: string } + | { kind: "module"; id: string } + | { kind: "status"; stripId: string; id: string }; + +export type DashboardTileItem = + | DashboardModule + | ServiceEntry + | StatusItem + | TelemetryCard; + +export type DashboardTileResolution = + | { + state: "ready"; + tile: DashboardTileReference; + item: DashboardTileItem; + } + | { + state: "not_found"; + tile: DashboardTileReference; + message: string; + } + | { + state: "disabled"; + tile: DashboardTileReference; + message: string; + }; export interface DatasourceResolutionOptions { fetch?: DatasourceFetch; + now?: () => number; prometheusBaseUrl?: string; prometheusRangeSeconds?: number; prometheusStepSeconds?: number; @@ -46,16 +76,90 @@ 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]; @@ -67,8 +171,12 @@ interface PrometheusMatrixResult { } function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext { + const fetch = options.fetch || globalThis.fetch; + return { - fetch: options.fetch || globalThis.fetch, + fetch, + fetchIdentity: datasourceFetchIdentity(fetch), + now: options.now || Date.now, prometheusBaseUrl: options.prometheusBaseUrl || process.env.PROMETHEUS_BASE_URL || @@ -79,6 +187,68 @@ 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, @@ -390,6 +560,66 @@ 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; @@ -673,3 +903,17 @@ 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/src/lib/server/db/connection.ts b/apps/web/src/lib/server/db/connection.ts similarity index 100% rename from src/lib/server/db/connection.ts rename to apps/web/src/lib/server/db/connection.ts diff --git a/src/lib/server/db/dashboard-store.test.ts b/apps/web/src/lib/server/db/dashboard-store.test.ts similarity index 97% rename from src/lib/server/db/dashboard-store.test.ts rename to apps/web/src/lib/server/db/dashboard-store.test.ts index ecb80f3..c2717f5 100644 --- a/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 "$lib/model/fixtures/generic"; -import type { DashboardDocument } from "$lib/model"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import type { DashboardDocument } from "@dimensionlab/dashboard-model"; import { DashboardPersistenceValidationError, createDashboardStore, diff --git a/src/lib/server/db/dashboard-store.ts b/apps/web/src/lib/server/db/dashboard-store.ts similarity index 99% rename from src/lib/server/db/dashboard-store.ts rename to apps/web/src/lib/server/db/dashboard-store.ts index 92c7a73..3928206 100644 --- a/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 "$lib/model"; +} from "@dimensionlab/dashboard-model"; import { type DashboardDatabaseConnection, openDashboardDatabase, diff --git a/src/lib/server/db/migrations.ts b/apps/web/src/lib/server/db/migrations.ts similarity index 100% rename from src/lib/server/db/migrations.ts rename to apps/web/src/lib/server/db/migrations.ts diff --git a/src/lib/server/db/model-migrations.test.ts b/apps/web/src/lib/server/db/model-migrations.test.ts similarity index 87% rename from src/lib/server/db/model-migrations.test.ts rename to apps/web/src/lib/server/db/model-migrations.test.ts index eb16b8f..5c863a0 100644 --- a/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 "$lib/model"; -import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import { DASHBOARD_SCHEMA_VERSION } from "@dimensionlab/dashboard-model"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { UnsupportedDashboardModelVersionError, migrateDashboardDocumentForPersistence, diff --git a/src/lib/server/db/model-migrations.ts b/apps/web/src/lib/server/db/model-migrations.ts similarity index 97% rename from src/lib/server/db/model-migrations.ts rename to apps/web/src/lib/server/db/model-migrations.ts index 92550e6..72e9c47 100644 --- a/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 "$lib/model"; +} from "@dimensionlab/dashboard-model"; export interface DashboardModelMigrationSuccess { valid: true; diff --git a/src/lib/server/db/schema.ts b/apps/web/src/lib/server/db/schema.ts similarity index 94% rename from src/lib/server/db/schema.ts rename to apps/web/src/lib/server/db/schema.ts index c148035..a3a7206 100644 --- a/src/lib/server/db/schema.ts +++ b/apps/web/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import type { DashboardDocument } from "$lib/model"; +import type { DashboardDocument } from "@dimensionlab/dashboard-model"; import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const dashboardDocuments = sqliteTable("dashboard_documents", { diff --git a/src/lib/testing/external-api-mocks.test.ts b/apps/web/src/lib/testing/external-api-mocks.test.ts similarity index 100% rename from src/lib/testing/external-api-mocks.test.ts rename to apps/web/src/lib/testing/external-api-mocks.test.ts diff --git a/src/lib/testing/external-api-mocks.ts b/apps/web/src/lib/testing/external-api-mocks.ts similarity index 100% rename from src/lib/testing/external-api-mocks.ts rename to apps/web/src/lib/testing/external-api-mocks.ts diff --git a/src/lib/ui/model-renderer.test.ts b/apps/web/src/lib/ui-adapter/model-renderer.test.ts similarity index 82% rename from src/lib/ui/model-renderer.test.ts rename to apps/web/src/lib/ui-adapter/model-renderer.test.ts index 62942ee..2f8178d 100644 --- a/src/lib/ui/model-renderer.test.ts +++ b/apps/web/src/lib/ui-adapter/model-renderer.test.ts @@ -1,13 +1,15 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { - type DashboardDocument, -} from "$lib/model"; -import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; -import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import { type DashboardDocument } from "@dimensionlab/dashboard-model"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { dashboardDocumentToUiDashboard } from "./model-renderer"; +const appRoot = process.cwd().endsWith(`${join("apps", "web")}`) + ? process.cwd() + : join(process.cwd(), "apps", "web"); + describe("dashboard model renderer", () => { test("projects the Dimension Lab model into UI component props", () => { const dashboard = dashboardDocumentToUiDashboard(dimensionLabDashboardFixture); @@ -86,7 +88,13 @@ describe("dashboard model renderer", () => { }); test("does not hardcode environment-specific content in mapper source", () => { - const source = readFileSync(join(process.cwd(), "src/lib/ui/model-renderer.ts"), "utf8").toLowerCase(); + const source = readFileSync( + join(appRoot, "src/lib/ui-adapter/model-renderer.ts"), + "utf8", + ) + .toLowerCase() + .replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model") + .replaceAll("@dimensionlab/ui", "@internal/ui"); expect(source).not.toContain("dimension"); expect(source).not.toContain("vaultwarden"); diff --git a/src/lib/ui/model-renderer.ts b/apps/web/src/lib/ui-adapter/model-renderer.ts similarity index 97% rename from src/lib/ui/model-renderer.ts rename to apps/web/src/lib/ui-adapter/model-renderer.ts index d5f3647..7ac1912 100644 --- a/src/lib/ui/model-renderer.ts +++ b/apps/web/src/lib/ui-adapter/model-renderer.ts @@ -6,7 +6,7 @@ import type { StatusItem, StatusStrip, TelemetryCard, -} from "$lib/model"; +} from "@dimensionlab/dashboard-model"; import type { UiDashboardPreview, UiModuleBlock, @@ -14,7 +14,7 @@ import type { UiServiceRow, UiStatusItem, UiTelemetryCard, -} from "./types"; +} from "@dimensionlab/ui"; export function dashboardDocumentToUiDashboard( document: DashboardDocument, diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts new file mode 100644 index 0000000..58cf203 --- /dev/null +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -0,0 +1,578 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +const root = existsSync(join(process.cwd(), "turbo.json")) + ? process.cwd() + : join(process.cwd(), "..", ".."); + +describe("workspace boundaries", () => { + test("declares the root as a turbo-managed bun workspace", () => { + const packageJson = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), + ) as { + private?: boolean; + scripts?: Record; + workspaces?: string[]; + }; + + expect(packageJson.private).toBe(true); + expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); + expect(packageJson.scripts?.build).toBe("turbo run 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"), + ) as { dependencies?: Record; name?: string }; + const uiPackage = JSON.parse( + readFileSync(join(root, "packages/ui/package.json"), "utf8"), + ) as { exports?: Record; name?: string }; + + expect(webPackage.name).toBe("@dimensionlab/web"); + expect(webPackage.dependencies?.["@dimensionlab/ui"]).toBe("workspace:*"); + expect(uiPackage.name).toBe("@dimensionlab/ui"); + 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/src/main.tsx b/apps/web/src/main.tsx similarity index 100% rename from src/main.tsx rename to apps/web/src/main.tsx diff --git a/src/page.test.tsx b/apps/web/src/page.test.tsx similarity index 97% rename from src/page.test.tsx rename to apps/web/src/page.test.tsx index fc68147..2ec5869 100644 --- a/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 "$lib/model/fixtures/generic"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { AppStateView, resolveDocumentMetadata } from "./App"; describe("home page model renderer", () => { diff --git a/src/server/dev.test.ts b/apps/web/src/server/dev.test.ts similarity index 78% rename from src/server/dev.test.ts rename to apps/web/src/server/dev.test.ts index f43f953..60031db 100644 --- a/src/server/dev.test.ts +++ b/apps/web/src/server/dev.test.ts @@ -2,6 +2,7 @@ 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", () => { @@ -22,4 +23,11 @@ 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/src/server/dev.ts b/apps/web/src/server/dev.ts similarity index 90% rename from src/server/dev.ts rename to apps/web/src/server/dev.ts index c515240..fa4dc2f 100644 --- a/src/server/dev.ts +++ b/apps/web/src/server/dev.ts @@ -3,6 +3,10 @@ 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(); @@ -51,7 +55,7 @@ export function runDevServers(): void { process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); - spawn("api server", [process.execPath, "src/server/index.ts"], { + spawn("api server", [process.execPath, ...apiServerArgs], { HOST: apiHost, PORT: apiPort, }); diff --git a/apps/web/src/server/index.test.ts b/apps/web/src/server/index.test.ts new file mode 100644 index 0000000..af1035d --- /dev/null +++ b/apps/web/src/server/index.test.ts @@ -0,0 +1,77 @@ +import { afterAll, describe, expect, test, vi } from "vitest"; +import { handleRequest } from "./index"; + +describe("server request routing", () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); + + afterAll(() => { + consoleInfo.mockRestore(); + }); + + test("routes dashboard tile batch requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/tiles", { + method: "POST", + body: JSON.stringify({ + tiles: [ + { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + ], + }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + state: "ready", + tiles: [ + { + state: "ready", + tile: { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + }, + ], + }); + }); + + test("rejects non-post dashboard tile batch requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/tiles", { + method: "GET", + }), + ); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + }); + + test("routes dashboard event stream requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/events", { + method: "GET", + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "text/event-stream; charset=utf-8", + ); + }); + + test("rejects non-get dashboard event stream requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/events", { + method: "POST", + }), + ); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + }); +}); diff --git a/src/server/index.ts b/apps/web/src/server/index.ts similarity index 77% rename from src/server/index.ts rename to apps/web/src/server/index.ts index 860d042..9cbcbd9 100644 --- a/src/server/index.ts +++ b/apps/web/src/server/index.ts @@ -1,6 +1,11 @@ import { extname, normalize } from "node:path"; import { handleAgentDashboardRoute } from "./routes/agent-dashboard"; -import { handleDashboardRoute } from "./routes/dashboard"; +import { + handleDashboardEventsRoute, + handleDashboardRoute, + handleDashboardTileRoute, + handleDashboardTilesRoute, +} from "./routes/dashboard"; const host = process.env.HOST || "0.0.0.0"; const port = Number(process.env.PORT || 3000); @@ -23,6 +28,21 @@ 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/src/server/routes/agent-dashboard.test.ts b/apps/web/src/server/routes/agent-dashboard.test.ts similarity index 100% rename from src/server/routes/agent-dashboard.test.ts rename to apps/web/src/server/routes/agent-dashboard.test.ts diff --git a/src/server/routes/agent-dashboard.ts b/apps/web/src/server/routes/agent-dashboard.ts similarity index 100% rename from src/server/routes/agent-dashboard.ts rename to apps/web/src/server/routes/agent-dashboard.ts diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts new file mode 100644 index 0000000..a4cf7ef --- /dev/null +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -0,0 +1,799 @@ +import { afterAll, afterEach, describe, expect, test, vi } from "vitest"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import { + createDashboardTileCache, + dashboardTileCacheKey, + handleDashboardEventsRoute, + handleDashboardTilesRoute, + handleDashboardTileRoute, + loadDashboardResponse, + loadDashboardTilesResponse, + loadDashboardTileResponse, + type DashboardTileResolutionLogEvent, +} from "./dashboard"; + +describe("dashboard API route", () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); + + afterEach(() => { + consoleInfo.mockClear(); + }); + + afterAll(() => { + consoleInfo.mockRestore(); + }); + + test("returns the ready dashboard shell without hydrating live datasources", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("live datasource fetch should not run for the shell response"), + ); + + 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.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 new file mode 100644 index 0000000..c42227c --- /dev/null +++ b/apps/web/src/server/routes/dashboard.ts @@ -0,0 +1,609 @@ +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"; + +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, + seedDocument: options.seedDocument, + }); + + if (dashboard.state !== "ready") { + return dashboard; + } + + if ( + !options.hydrateLiveDatasources || + options.disableLiveDatasources || + process.env.DISABLE_LIVE_DATASOURCES === "1" + ) { + return { + ...dashboard, + liveDatasourceHydration: { + enabled: liveHydrationEnabled, + }, + }; + } + + 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), + }; +} + +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/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to apps/web/src/vite-env.d.ts diff --git a/static/mockServiceWorker.js b/apps/web/static/mockServiceWorker.js similarity index 100% rename from static/mockServiceWorker.js rename to apps/web/static/mockServiceWorker.js diff --git a/tests/e2e/dashboard.spec.ts b/apps/web/tests/e2e/dashboard.spec.ts similarity index 70% rename from tests/e2e/dashboard.spec.ts rename to apps/web/tests/e2e/dashboard.spec.ts index b432d14..3eb0944 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/apps/web/tests/e2e/dashboard.spec.ts @@ -16,7 +16,6 @@ const linkedServiceIds = [ "open-webui", "comfyui", "models", - "prompt-registry", "adminer", "assistant", "suna", @@ -104,11 +103,66 @@ test.describe("dashboard page QA gate", () => { expect(metrics.runtimeBottom).toBeLessThanOrEqual(956); expect(metrics.footerBottom).toBeLessThanOrEqual(956); expect(metrics.telemetryCardCount).toBe(16); - expect(metrics.serviceRowCount).toBe(28); + expect(metrics.serviceRowCount).toBe(27); expect(metrics.footerCellCount).toBe(5); expect(metrics.clippedItems).toEqual([]); }); + test("keeps intermediate viewports scrollable without horizontal clipping", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop"); + + for (const viewport of [ + { width: 900, height: 956 }, + { width: 1024, height: 768 }, + ]) { + await page.setViewportSize(viewport); + await page.goto("/"); + await waitForDashboardReady(page); + + const metrics = await page.evaluate(() => { + const footer = document.querySelector("[data-model-id='footer-status']"); + const trackedElements = [ + document.querySelector(".dashboard-frame__header"), + ...document.querySelectorAll(".telemetry-card"), + ...document.querySelectorAll(".service-panel"), + document.querySelector("[data-model-id='runtime-health']"), + footer, + ].filter((element): element is Element => Boolean(element)); + + const footerRect = footer?.getBoundingClientRect(); + const clippedRight = trackedElements + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + id: element.getAttribute("data-model-id") || element.className, + right: rect.right, + width: rect.width, + }; + }) + .filter((item) => item.right > window.innerWidth + 1 || item.width <= 0); + + return { + clippedRight, + footerBottomInDocument: (footerRect?.bottom ?? 0) + window.scrollY, + frameOverflow: window.getComputedStyle( + document.querySelector(".dashboard-frame") as Element, + ).overflow, + scrollHeight: document.documentElement.scrollHeight, + scrollWidth: document.documentElement.scrollWidth, + }; + }); + + expect(metrics.scrollWidth).toBeLessThanOrEqual(viewport.width); + expect(metrics.scrollHeight).toBeGreaterThanOrEqual( + Math.ceil(metrics.footerBottomInDocument), + ); + expect(metrics.frameOverflow).not.toBe("hidden"); + expect(metrics.clippedRight).toEqual([]); + } + }); + test("renders bookmark rows as a compact divider list", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); @@ -209,6 +263,15 @@ test.describe("dashboard page QA gate", () => { await waitForDashboardReady(page); await expect(page.getByRole("main")).toHaveCount(1); + await page.keyboard.press("Tab"); + const themeToggle = page.getByRole("button", { name: "Light theme" }); + await expect(themeToggle).toBeFocused(); + await expect(themeToggle).toHaveAttribute("aria-pressed", "false"); + const themeFocusBoxShadow = await themeToggle.evaluate((element) => { + return window.getComputedStyle(element).boxShadow; + }); + expect(themeFocusBoxShadow).not.toBe("none"); + for (const serviceId of linkedServiceIds) { await page.keyboard.press("Tab"); @@ -230,6 +293,28 @@ test.describe("dashboard page QA gate", () => { expect(results.violations).toEqual([]); }); + test("passes automated accessibility checks in light mode", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop"); + + await page.goto("/"); + await waitForDashboardReady(page); + await page.getByRole("button", { name: "Light theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); + await expect(page.getByRole("button", { name: "Light theme" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + + await expect(page).toHaveScreenshot("dashboard-light-desktop.png", { + fullPage: true, + }); + }); + test("honors reduced-motion preferences", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); @@ -255,6 +340,31 @@ test.describe("dashboard page QA gate", () => { 0.01, ); }); + + test("toggles the dashboard between dark and light themes", async ({ page }) => { + await page.goto("/"); + await waitForDashboardReady(page); + + await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "dark"); + + const switchToLight = page.getByRole("button", { + name: "Light theme", + }); + await expect(switchToLight).toBeVisible(); + await expect(switchToLight).toHaveAttribute("aria-pressed", "false"); + await switchToLight.click(); + + await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); + await expect(page.getByRole("button", { name: "Light theme" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await page.reload(); + await waitForDashboardReady(page); + + await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); + }); }); async function waitForDashboardReady(page: Page): Promise { 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 new file mode 100644 index 0000000..a1c0ada Binary files /dev/null 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 new file mode 100644 index 0000000..f5d5163 Binary files /dev/null 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 new file mode 100644 index 0000000..48293a4 Binary files /dev/null and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png differ diff --git a/apps/web/tests/e2e/storybook-server.ts b/apps/web/tests/e2e/storybook-server.ts new file mode 100644 index 0000000..7544654 --- /dev/null +++ b/apps/web/tests/e2e/storybook-server.ts @@ -0,0 +1,35 @@ +import { existsSync } from "node:fs"; +import { resolve, sep } from "node:path"; + +const root = resolve(process.cwd(), "packages/ui/storybook-static"); +const port = Number(process.env.STORYBOOK_STATIC_PORT || 6007); + +if (!existsSync(resolve(root, "iframe.html"))) { + throw new Error( + "packages/ui/storybook-static is missing. Run bun run build-storybook first.", + ); +} + +Bun.serve({ + hostname: "127.0.0.1", + port, + async fetch(request) { + const url = new URL(request.url); + const pathname = decodeURIComponent(url.pathname); + const relativePath = pathname === "/" ? "/index.html" : pathname; + const filePath = resolve(root, `.${relativePath}`); + + if (!filePath.startsWith(`${root}${sep}`)) { + return new Response("Forbidden", { status: 403 }); + } + + const file = Bun.file(filePath); + if (!(await file.exists())) { + return new Response("Not found", { status: 404 }); + } + + return new Response(file); + }, +}); + +console.log(`Storybook static listening on http://127.0.0.1:${port}`); diff --git a/apps/web/tests/e2e/storybook.spec.ts b/apps/web/tests/e2e/storybook.spec.ts new file mode 100644 index 0000000..47a58ba --- /dev/null +++ b/apps/web/tests/e2e/storybook.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; + +const storybookPort = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT || 6007); +const storybookURL = `http://127.0.0.1:${storybookPort}`; + +test.describe("Storybook theme QA", () => { + test("renders the ThemeToggle light story on the light canvas", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop"); + + const consoleMessages: string[] = []; + page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) { + consoleMessages.push(message.text()); + } + }); + + const url = `${storybookURL}/iframe.html?id=ui-themetoggle--light&viewMode=story`; + await page.goto(url, { waitUntil: "networkidle" }); + + await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); + await expect( + page.getByRole("button", { name: "Light theme" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Light theme" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(page.locator("body")).toHaveCSS( + "background-color", + "rgb(238, 242, 231)", + ); + expect( + consoleMessages.filter((message) => + message.includes("Global args/argTypes can only be set globally"), + ), + ).toEqual([]); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..120931b --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "$lib/*": ["src/lib/*"] + }, + "types": ["node", "bun-types", "react", "react-dom", "vite/client"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "vite.config.ts", + "playwright.config.ts", + "drizzle.config.ts" + ], + "exclude": ["build", "dist", "node_modules"] +} diff --git a/vite.config.ts b/apps/web/vite.config.ts similarity index 100% rename from vite.config.ts rename to apps/web/vite.config.ts diff --git a/bun.lock b/bun.lock index 36ab7d6..d6915d0 100644 --- a/bun.lock +++ b/bun.lock @@ -4,45 +4,92 @@ "workspaces": { "": { "name": "dimensionlab-website", + "devDependencies": { + "turbo": "^2.5.0", + }, + }, + "apps/web": { + "name": "@dimensionlab/web", + "version": "0.0.1", "dependencies": { - "@fontsource-variable/geist": "^5.2.9", - "@iconify/react": "^6.0.2", + "@dimensionlab/dashboard-model": "workspace:*", + "@dimensionlab/ui": "workspace:*", "@sinclair/typebox": "^0.34.49", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", - "lucide-react": "^1.21.0", - "radix-ui": "^1.6.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", - "uplot": "^1.6.32", }, "devDependencies": { "@axe-core/playwright": "^4.11.3", "@playwright/test": "^1.61.0", - "@storybook/addon-a11y": "^10.4.6", - "@storybook/addon-vitest": "^10.4.6", - "@storybook/react-vite": "^10.4.6", "@tailwindcss/vite": "^4.3.1", "@types/bun": "^1.3.14", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "bun-types": "^1.3.14", "drizzle-kit": "^0.31.10", "msw": "^2.14.6", "shadcn": "^4.11.0", - "storybook": "^10.4.6", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vite": "^8.0.16", "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", + "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", + }, + "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", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -107,6 +154,12 @@ "@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"], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.74.3", "", { "dependencies": { "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-+VjTIiOCApjB0K4a41+SkN18gTetmhU9UN2JD8LHeNUqo/38FmtgvdtWsW/WWN1dyYRGS7XKeAAb/ruy7x1tRw=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -123,57 +176,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -587,6 +640,18 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -625,7 +690,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@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/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/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=="], @@ -635,7 +700,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@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], "@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=="], @@ -655,7 +720,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -669,7 +734,7 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], - "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], + "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -699,7 +764,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], - "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=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -819,7 +884,7 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -827,7 +892,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@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -945,7 +1010,7 @@ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -975,7 +1040,7 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1101,7 +1166,7 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "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=="], + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -1331,6 +1396,8 @@ "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], @@ -1387,7 +1454,7 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1405,6 +1472,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@axe-core/playwright/axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1429,7 +1498,7 @@ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@storybook/addon-a11y/axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], + "@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=="], @@ -1447,14 +1516,6 @@ "@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=="], @@ -1467,12 +1528,12 @@ "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1489,8 +1550,6 @@ "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -1501,24 +1560,20 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "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/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "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=="], - "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "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=="], - "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1533,6 +1588,10 @@ "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + "@dotenvx/dotenvx/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "@dotenvx/dotenvx/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1579,128 +1638,78 @@ "@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=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "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/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "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/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "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/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "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=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "storybook/open/wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], } } diff --git a/components.json b/components.json deleted file mode 100644 index f8d8d59..0000000 --- a/components.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "radix-nova", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "$lib/components", - "utils": "$lib/utils", - "ui": "$lib/components/ui", - "lib": "$lib/.", - "hooks": "$lib/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} -} diff --git a/docs/superpowers/plans/2026-06-20-turbo-component-library.md b/docs/superpowers/plans/2026-06-20-turbo-component-library.md new file mode 100644 index 0000000..22d2ad8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-turbo-component-library.md @@ -0,0 +1,436 @@ +# Turbo Component Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert the single-package Dimension Lab website into a Turborepo workspace where `apps/web` consumes a compiled reusable React component library from `packages/ui`. + +**Architecture:** The root becomes a private Bun workspace with Turbo orchestration. `packages/ui` owns generic reusable UI components, CSS, theme helpers, Storybook, and package-level tests. `apps/web` owns the website runtime, model/server/database logic, the model-to-UI adapter, e2e tests, and deployment container. + +**Tech Stack:** Bun workspaces, Turborepo, Vite, React 19, TypeScript, Storybook React Vite, Tailwind CSS v4, shadcn CSS, Vitest, Playwright, Drizzle ORM, Bun SQLite. + +--- + +## File Structure + +- Create root `turbo.json`: cacheable task graph for `build`, `check`, `test:unit`, `build-storybook`, `test:e2e`, and `test:qa`. +- Create root `tsconfig.base.json`: shared strict TypeScript defaults. +- Modify root `package.json`: private workspace root with `apps/*` and `packages/*`, Turbo scripts, and `turbo` dev dependency only. +- Create `packages/ui/package.json`: compiled `@dimensionlab/ui` package with code and CSS exports. +- Create `packages/ui/tsconfig.json` and `packages/ui/tsconfig.build.json`: package typecheck and declaration/JS build config. +- Create `packages/ui/src/styles.css`: library style entry importing font, uPlot CSS, tokens, and component CSS. +- Move `src/lib/ui/components/**` to `packages/ui/src/components/**`. +- Move `src/lib/ui/stories/**` to `packages/ui/src/stories/**`. +- Move `src/lib/ui/tokens.css` to `packages/ui/src/tokens.css`. +- Move `src/lib/ui/theme.ts`, `types.ts`, `format.ts`, `fixtures.ts`, and `index.ts` to `packages/ui/src/**`. +- Move `src/lib/ui/components/styles.css` to `packages/ui/src/components/styles.css`. +- Move `.storybook/**` to `packages/ui/.storybook/**`. +- Move unused shadcn primitives from `src/lib/components/ui/**` to `packages/ui/src/primitives/**` and update their `cn` import to package-local `src/utils.ts`. +- Move `src/lib/utils.ts` to `packages/ui/src/utils.ts`. +- Move app runtime files into `apps/web`: `src/App.tsx`, `src/main.tsx`, `src/app.css`, `src/server/**`, `src/lib/model/**`, `src/lib/server/**`, `src/lib/testing/**`, `src/vite-env.d.ts`, `src/page.test.tsx`, `src/server/dev.ts`, `tests/**`, `drizzle/**`, `Containerfile`, `index.html`, `playwright.config.ts`, `vite.config.ts`, `drizzle.config.ts`, and app-specific README/deployment files. +- Move `src/lib/ui/model-renderer.ts` and `model-renderer.test.ts` into `apps/web/src/lib/ui-adapter/**`. +- Create `apps/web/package.json`, `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, and `apps/web/playwright.config.ts`. +- Update `apps/web/src/App.tsx` to import components/types from `@dimensionlab/ui` and import the adapter from `$lib/ui-adapter/model-renderer`. +- Update `apps/web/src/app.css` to import `@dimensionlab/ui/styles.css` instead of local UI CSS files. +- Update tests that read paths so package boundary tests inspect `packages/ui` and app tests inspect `apps/web`. +- Update `README.md` to describe the workspace commands, package boundaries, Storybook location, and deployment path. + +## Task 1: Workspace And Boundary Tests + +**Files:** +- Modify: `package.json` +- Create: `turbo.json` +- Create: `tsconfig.base.json` +- Create: `packages/ui/package.json` +- Create: `packages/ui/tsconfig.json` +- Create: `packages/ui/tsconfig.build.json` +- Create: `apps/web/package.json` +- Create: `apps/web/tsconfig.json` +- Create: `apps/web/src/lib/workspace-boundary.test.ts` + +- [ ] **Step 1: Write the failing workspace boundary test** + +Create `apps/web/src/lib/workspace-boundary.test.ts`: + +```ts +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +const root = join(import.meta.dir, "../../../.."); + +describe("workspace boundaries", () => { + test("declares the root as a turbo-managed bun workspace", () => { + const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + private?: boolean; + scripts?: Record; + workspaces?: string[]; + }; + + expect(packageJson.private).toBe(true); + expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); + expect(packageJson.scripts?.build).toBe("turbo build"); + expect(existsSync(join(root, "turbo.json"))).toBe(true); + }); + + test("keeps the website app and reusable UI library as separate packages", () => { + const webPackage = JSON.parse( + readFileSync(join(root, "apps/web/package.json"), "utf8"), + ) as { dependencies?: Record; name?: string }; + const uiPackage = JSON.parse( + readFileSync(join(root, "packages/ui/package.json"), "utf8"), + ) as { exports?: Record; name?: string }; + + expect(webPackage.name).toBe("@dimensionlab/web"); + expect(webPackage.dependencies?.["@dimensionlab/ui"]).toBe("workspace:*"); + expect(uiPackage.name).toBe("@dimensionlab/ui"); + expect(uiPackage.exports).toHaveProperty("."); + expect(uiPackage.exports).toHaveProperty("./styles.css"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```sh +bunx vitest run apps/web/src/lib/workspace-boundary.test.ts +``` + +Expected: FAIL because `apps/web`, `packages/ui`, and `turbo.json` do not exist. + +- [ ] **Step 3: Add minimal workspace manifests** + +Create root `package.json` as the workspace orchestrator: + +```json +{ + "name": "dimensionlab", + "version": "0.0.1", + "private": true, + "type": "module", + "packageManager": "bun@1.3.14", + "workspaces": ["apps/*", "packages/*"], + "scripts": { + "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" + } +} +``` + +Create `turbo.json`: + +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "build/**"] + }, + "check": { + "dependsOn": ["^build"], + "outputs": [] + }, + "test:unit": { + "dependsOn": ["^build"], + "outputs": [] + }, + "build-storybook": { + "dependsOn": ["^build"], + "outputs": ["storybook-static/**"] + }, + "test:e2e": { + "dependsOn": ["build", "^build"], + "outputs": ["test-results/**", "playwright-report/**"] + }, + "test:qa": { + "dependsOn": ["check", "test:unit", "build", "build-storybook", "test:e2e"], + "outputs": [] + }, + "dev": { + "cache": false, + "persistent": true + }, + "storybook": { + "cache": false, + "persistent": true + } + } +} +``` + +Create minimal `packages/ui/package.json` with `@dimensionlab/ui` exports and +minimal `apps/web/package.json` with `@dimensionlab/ui` as a workspace +dependency. Move the full dependency lists in Task 2 and Task 3. + +- [ ] **Step 4: Run the boundary test** + +Run: + +```sh +bunx vitest run apps/web/src/lib/workspace-boundary.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add package.json turbo.json tsconfig.base.json apps/web/package.json apps/web/tsconfig.json apps/web/src/lib/workspace-boundary.test.ts packages/ui/package.json packages/ui/tsconfig.json packages/ui/tsconfig.build.json +git commit -m "build: add turbo workspace manifests" +``` + +## Task 2: Extract The UI Package + +**Files:** +- Move: `src/lib/ui/components/**` to `packages/ui/src/components/**` +- Move: `src/lib/ui/stories/**` to `packages/ui/src/stories/**` +- Move: `src/lib/ui/{index.ts,types.ts,theme.ts,format.ts,fixtures.ts,tokens.css}` to `packages/ui/src/**` +- Move: `.storybook/**` to `packages/ui/.storybook/**` +- Move: `src/lib/components/ui/**` to `packages/ui/src/primitives/**` +- Move: `src/lib/utils.ts` to `packages/ui/src/utils.ts` +- Create: `packages/ui/src/styles.css` +- Modify: `packages/ui/src/index.ts` +- Modify: `packages/ui/src/storybook.test.ts` +- Modify: `packages/ui/src/content-boundary.test.ts` + +- [ ] **Step 1: Write the failing UI isolation assertion** + +Update the package boundary tests to read from `packages/ui/src` and assert no +imports from `apps/web`, `$lib/server`, or `$lib/model` exist: + +```ts +expect(source).not.toMatch(/from ["'](?:apps\/web|\$lib\/server|\$lib\/model)/); +``` + +Run: + +```sh +bunx vitest run packages/ui/src/content-boundary.test.ts +``` + +Expected: FAIL until the files move and the app-specific adapter is removed. + +- [ ] **Step 2: Move generic UI files** + +Run mechanical moves with `git mv`. Move `model-renderer.ts` out of the UI +package in Task 3 rather than into `packages/ui`. + +- [ ] **Step 3: Add the UI style entry** + +Create `packages/ui/src/styles.css`: + +```css +@import "@fontsource-variable/geist"; +@import "uplot/dist/uPlot.min.css"; +@import "./tokens.css"; +@import "./components/styles.css"; +``` + +- [ ] **Step 4: Make package imports relative or package-local** + +Update moved primitive files to import `cn` from `../utils`. +Remove `dashboardDocumentToUiDashboard` from `packages/ui/src/index.ts`. + +- [ ] **Step 5: Build the package** + +Run: + +```sh +bun run --cwd packages/ui build +bun run --cwd packages/ui check +bun run --cwd packages/ui test:unit +``` + +Expected: PASS and `packages/ui/dist` contains `index.js`, `index.d.ts`, and +CSS files. + +- [ ] **Step 6: Commit** + +```sh +git add packages/ui src/lib/ui src/lib/components src/lib/utils.ts +git commit -m "refactor(ui): extract reusable component package" +``` + +## Task 3: Move The Web App Workspace + +**Files:** +- Move: `src/**` app files that are not reusable UI to `apps/web/src/**` +- Move: `tests/**` to `apps/web/tests/**` +- Move: `drizzle/**` to `apps/web/drizzle/**` +- Move: `Containerfile` to `apps/web/Containerfile` +- Move: `index.html`, `vite.config.ts`, `playwright.config.ts`, `drizzle.config.ts` to `apps/web/**` +- Create: `apps/web/src/lib/ui-adapter/model-renderer.ts` +- Modify: `apps/web/src/App.tsx` +- Modify: `apps/web/src/app.css` + +- [ ] **Step 1: Move the model adapter out of UI** + +Move `src/lib/ui/model-renderer.ts` to +`apps/web/src/lib/ui-adapter/model-renderer.ts` and update imports from +`$lib/model` plus UI types from `@dimensionlab/ui`. + +- [ ] **Step 2: Move app runtime and tests** + +Use `git mv` for app/server/model/test/deployment files into `apps/web`. + +- [ ] **Step 3: Update app imports** + +In `apps/web/src/App.tsx`, import UI components and types from +`@dimensionlab/ui`, and import `dashboardDocumentToUiDashboard` from +`$lib/ui-adapter/model-renderer`. + +In `apps/web/src/app.css`, replace local UI imports with: + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "@dimensionlab/ui/styles.css"; +``` + +- [ ] **Step 4: Run app typecheck and unit tests** + +Run: + +```sh +bun run --cwd apps/web check +bun run --cwd apps/web test:unit +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add apps/web src tests drizzle Containerfile index.html vite.config.ts playwright.config.ts drizzle.config.ts +git commit -m "refactor(web): move website into app workspace" +``` + +## Task 4: Wire Turbo, Storybook, Playwright, And Container + +**Files:** +- Modify: `apps/web/playwright.config.ts` +- Modify: `apps/web/Containerfile` +- Modify: `apps/web/vite.config.ts` +- Modify: `packages/ui/.storybook/main.ts` +- Modify: `packages/ui/.storybook/preview.ts` +- Modify: `tests/e2e/storybook-server.ts` after move to `apps/web/tests/e2e/storybook-server.ts` +- Modify: `README.md` + +- [ ] **Step 1: Update Playwright commands for workspaces** + +In `apps/web/playwright.config.ts`, make the web server command build from the +workspace root or app directory consistently: + +```ts +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` +``` + +This command runs inside `apps/web` when invoked through the app package script. + +- [ ] **Step 2: Update Storybook package paths** + +`packages/ui/.storybook/main.ts` should use `../src/**/*.stories.@(js|ts|tsx)`. +`packages/ui/.storybook/preview.ts` should import `../src/styles.css` and use +generic MSW handlers only if they are moved into the UI package. + +- [ ] **Step 3: Update the container build** + +`apps/web/Containerfile` should build from the repository root context or copy +only the workspace files it needs. Preserve the runtime command: + +```dockerfile +CMD ["bun", "build/index.js"] +``` + +- [ ] **Step 4: Run full root checks** + +Run: + +```sh +bun install +bun run check +bun run test:unit +bun run build +bun run build-storybook +bun run test:e2e +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add README.md apps/web packages/ui package.json turbo.json bun.lock +git commit -m "build: wire turbo workspace qa" +``` + +## Task 5: Final QA, PR, Review, Merge, Deploy + +**Files:** +- No planned source edits unless verification finds blockers. + +- [ ] **Step 1: Run release gate** + +Run: + +```sh +bun run test:qa +``` + +Expected: PASS. + +- [ ] **Step 2: Push and open PR** + +Run: + +```sh +git push -u origin codex/turbo-component-library +``` + +Open a ready PR against `main` titled: + +```text +refactor: migrate dashboard to turbo component library +``` + +- [ ] **Step 3: Independent review** + +Send an independent reviewer to inspect the PR diff against the objective: +Turbo repo, `apps/web`, compiled `packages/ui`, reusable components removed +from app, Storybook with UI package, root QA passing, no server behavior change. + +- [ ] **Step 4: Resolve blockers** + +For each blocking review finding, write or update a failing test first, verify +the failure, implement the fix, run targeted checks, commit, push, and re-review. + +- [ ] **Step 5: Merge and deploy** + +When review and checks are clean, merge the PR into `main`, sync the production +checkout, rebuild the Podman image, restart `dimensionlab-website.service`, and +verify `https://dimensionlab.net/` with a browser smoke check. + +## Self-Review + +- Spec coverage: every completion criterion in the design maps to Task 1 through + Task 5. +- Placeholder scan: no task says TBD, TODO, or "add tests" without commands. +- Type consistency: package names are consistently `@dimensionlab/ui` and + `@dimensionlab/web`; the app adapter path is consistently + `$lib/ui-adapter/model-renderer`. diff --git a/docs/superpowers/specs/2026-06-20-turbo-component-library-design.md b/docs/superpowers/specs/2026-06-20-turbo-component-library-design.md new file mode 100644 index 0000000..ba0fefc --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-turbo-component-library-design.md @@ -0,0 +1,186 @@ +# Turbo Component Library Migration Design + +## Context + +The current Dimension Lab website is a single Bun/Vite React package. It owns +the browser app, Bun production server, dashboard model, persistence, datasource +adapters, reusable UI components, Storybook, Playwright checks, and container +deployment from one `package.json`. + +The UI components are already mostly generic and content-free under +`src/lib/ui`, but they are not reusable by another project because they live +inside the app package, depend on app path aliases, and share the app build, +test, and Storybook configuration. One file in that area, +`src/lib/ui/model-renderer.ts`, imports the Dimension Lab dashboard model and +therefore is an app adapter, not reusable component-library code. + +The migration goal is to turn the repository into a Turborepo workspace where +the Dimension Lab website consumes a separate reusable React component package. + +## Target Repository Shape + +Use one application workspace and one component-library workspace: + +```text +. +├── apps/ +│ └── web/ +│ ├── src/ +│ ├── tests/ +│ ├── drizzle/ +│ ├── Containerfile +│ └── package.json +├── packages/ +│ └── ui/ +│ ├── src/ +│ ├── .storybook/ +│ ├── package.json +│ └── tsconfig.json +├── package.json +├── turbo.json +├── tsconfig.base.json +└── bun.lock +``` + +The root package is private and contains only workspace orchestration: +workspaces, Turbo scripts, shared dev dependencies where useful, and the lock +file. Runtime dependencies belong to the workspace that imports them. + +## Package Ownership + +`packages/ui` is a compiled React library named `@dimensionlab/ui`. + +It owns: + +- Reusable React components currently under `src/lib/ui/components`. +- UI CSS tokens and component styles. +- Theme helpers currently under `src/lib/ui/theme.ts`. +- Generic UI prop/data types currently under `src/lib/ui/types.ts`. +- Generic formatting helpers currently under `src/lib/ui/format.ts`. +- Generic Storybook stories and story fixtures. +- UI render tests, boundary tests, and Storybook inventory tests. + +It must not import from the website app, the dashboard model, server modules, +database modules, datasource modules, or deployment files. + +The library publishes explicit package exports: + +- `@dimensionlab/ui` for component and type exports. +- `@dimensionlab/ui/styles.css` for the combined token/component CSS entry. +- Optional explicit subpath exports for future direct imports where useful. + +The compiled output goes to `packages/ui/dist` and includes JavaScript, +declaration files, and copied CSS. The package stays private for now but is +structured so it can later be published or moved into another Dimension Lab repo +without taking the website runtime with it. + +`apps/web` owns: + +- The Vite React browser app. +- The Bun production server and API routes. +- Dashboard model, fixtures, schema, validation, and model migrations. +- Drizzle/SQLite persistence and checked-in SQL migrations. +- Datasource adapters and runtime dashboard loading. +- Agent dashboard configuration endpoint. +- The `dashboardDocumentToUiDashboard` adapter that maps the app model to UI + package props. +- Playwright e2e tests and deployment container. + +## Why A Compiled Package + +The component package should be compiled rather than a just-in-time source +package. This is slightly more setup, but it better fits reuse outside the +current app because consumers can import stable JavaScript and declarations +instead of relying on their bundler to transpile this repo's TypeScript source. +It also gives Turbo a cacheable `@dimensionlab/ui#build` task. + +## Build And Task Graph + +Root scripts delegate through Turbo: + +- `bun run dev` runs the web dev server and any required dependency tasks. +- `bun run build` runs package builds in dependency order. +- `bun run check` runs TypeScript checks for all workspaces. +- `bun run test:unit` runs Vitest unit tests for all workspaces. +- `bun run build-storybook` builds Storybook from `packages/ui`. +- `bun run test:e2e` runs the web app Playwright suite. +- `bun run test:qa` is the full release gate. + +`turbo.json` defines `build`, `check`, `test:unit`, `build-storybook`, +`test:e2e`, and `test:qa` tasks. Build outputs include `dist/**`, +`storybook-static/**`, and `build/**` as appropriate. + +The web app depends on `@dimensionlab/ui` using Bun workspace syntax. The web +Vite config aliases `$lib` to `apps/web/src/lib`; the UI package should not use +that app alias. + +## Storybook + +Storybook moves with the component package. It should load +`@dimensionlab/ui/styles.css`, use React Vite Storybook, and keep the existing +generic story inventory. Environment-specific Dimension Lab labels, hostnames, +links, fallback values, and datasource names remain forbidden in package UI +source and stories. + +The repository should no longer have a root Storybook tied to the web app. + +## Deployment + +The deployed website remains the same service from the outside: + +- Production command remains `bun build/index.js` inside the runtime image. +- The container still exposes port `3000` and mounts `/data`. +- Runtime environment variables and database behavior remain unchanged. + +The `Containerfile` moves to `apps/web/Containerfile` or remains root with +updated workspace-aware copy/build steps. The chosen layout must preserve the +existing Podman service contract used by `dimensionlab-website.service`. + +## Testing Strategy + +The migration must add or update tests that prove the new boundaries: + +- The root package is a Bun workspace with `apps/*` and `packages/*`. +- The web app imports UI from `@dimensionlab/ui`, not from local copied + component files. +- `packages/ui` does not import from `apps/web`, `$lib/server`, `$lib/model`, + or any website runtime module. +- `dashboardDocumentToUiDashboard` lives in `apps/web` and is tested there. +- Storybook inventory is evaluated against `packages/ui`. +- The full QA gate still covers typecheck, unit tests, app build, Storybook + build, and Playwright desktop/mobile checks. + +## Completion Criteria + +The migration is complete only when current evidence proves all of these: + +- Root `package.json` is a private workspace root with Turbo scripts. +- `turbo.json` exists and models the workspace task graph. +- The web application lives under `apps/web`. +- The reusable React component library lives under `packages/ui`. +- `packages/ui/package.json` is named `@dimensionlab/ui` and has compiled + exports for code, types, and CSS. +- The web app depends on `@dimensionlab/ui` through the workspace. +- Reusable components and Storybook have been removed from the web app package. +- App-specific model/server/database/datasource code has not moved into + `packages/ui`. +- `dashboardDocumentToUiDashboard` is outside the UI package. +- `bun run check` passes from the root. +- `bun run test:unit` passes from the root. +- `bun run build` passes from the root. +- `bun run build-storybook` passes from the root. +- `bun run test:e2e` passes from the root. +- The production container can still be built and run with the same service + contract. + +## Migration Approach + +Implement this on branch `codex/turbo-component-library` in focused commits: + +1. Add the workspace/Turbo scaffolding and boundary tests. +2. Move UI code and Storybook to `packages/ui`. +3. Move the app runtime into `apps/web` and wire it to `@dimensionlab/ui`. +4. Move the model-to-UI adapter into the web app. +5. Update build, test, Playwright, Storybook, README, and container paths. +6. Run the full QA gate, push a ready PR, perform independent review, resolve + blockers, merge to `main`, and deploy only when checks and review are clean. diff --git a/package.json b/package.json index f1cf4ab..e33d3aa 100644 --- a/package.json +++ b/package.json @@ -1,63 +1,28 @@ { - "name": "dimensionlab-website", + "name": "dimensionlab", "version": "0.0.1", "private": true, "type": "module", + "packageManager": "bun@1.3.14", + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "bun src/server/dev.ts", - "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", - "storybook": "storybook dev -p 6006 --host 0.0.0.0", - "build-storybook": "storybook build", - "check": "tsc --noEmit", - "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 build-storybook && bun run test:e2e", - "db:generate": "drizzle-kit generate", - "db:check": "drizzle-kit check" - }, - "dependencies": { - "@fontsource-variable/geist": "^5.2.9", - "@iconify/react": "^6.0.2", - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.21.0", - "radix-ui": "^1.6.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0", - "uplot": "^1.6.32" + "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" }, "devDependencies": { - "@axe-core/playwright": "^4.11.3", - "@playwright/test": "^1.61.0", - "@storybook/addon-a11y": "^10.4.6", - "@storybook/addon-vitest": "^10.4.6", - "@storybook/react-vite": "^10.4.6", - "@tailwindcss/vite": "^4.3.1", - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "drizzle-kit": "^0.31.10", - "msw": "^2.14.6", - "shadcn": "^4.11.0", - "storybook": "^10.4.6", - "tailwindcss": "^4.3.1", - "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.9" - }, - "msw": { - "workerDirectory": [ - "static" - ] + "turbo": "^2.5.0" } } diff --git a/packages/dashboard-model/package.json b/packages/dashboard-model/package.json new file mode 100644 index 0000000..ae9a49b --- /dev/null +++ b/packages/dashboard-model/package.json @@ -0,0 +1,36 @@ +{ + "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/src/lib/model/fixtures/generic.ts b/packages/dashboard-model/src/fixtures/generic.ts similarity index 100% rename from src/lib/model/fixtures/generic.ts rename to packages/dashboard-model/src/fixtures/generic.ts diff --git a/packages/dashboard-model/src/fixtures/index.ts b/packages/dashboard-model/src/fixtures/index.ts new file mode 100644 index 0000000..f62d646 --- /dev/null +++ b/packages/dashboard-model/src/fixtures/index.ts @@ -0,0 +1 @@ +export { genericDashboardFixture } from "./generic"; diff --git a/src/lib/model/index.ts b/packages/dashboard-model/src/index.ts similarity index 100% rename from src/lib/model/index.ts rename to packages/dashboard-model/src/index.ts diff --git a/src/lib/model/schema.test.ts b/packages/dashboard-model/src/schema.test.ts similarity index 96% rename from src/lib/model/schema.test.ts rename to packages/dashboard-model/src/schema.test.ts index 7eeed74..9975f87 100644 --- a/src/lib/model/schema.test.ts +++ b/packages/dashboard-model/src/schema.test.ts @@ -5,7 +5,6 @@ import { validateDashboardDocument, } from "."; import { - dimensionLabDashboardFixture, genericDashboardFixture, } from "./fixtures"; @@ -19,12 +18,6 @@ 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/src/lib/model/schema.ts b/packages/dashboard-model/src/schema.ts similarity index 100% rename from src/lib/model/schema.ts rename to packages/dashboard-model/src/schema.ts diff --git a/src/lib/model/validation.ts b/packages/dashboard-model/src/validation.ts similarity index 100% rename from src/lib/model/validation.ts rename to packages/dashboard-model/src/validation.ts diff --git a/packages/dashboard-model/tsconfig.build.json b/packages/dashboard-model/tsconfig.build.json new file mode 100644 index 0000000..5d3e7ce --- /dev/null +++ b/packages/dashboard-model/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..26cb1c4 --- /dev/null +++ b/packages/dashboard-model/tsconfig.json @@ -0,0 +1,9 @@ +{ + "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 new file mode 160000 index 0000000..a7a4720 --- /dev/null +++ b/packages/ui @@ -0,0 +1 @@ +Subproject commit a7a472083555152b8e1a2dc018d3be5b3b10d60b diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh new file mode 100755 index 0000000..4fac4cd --- /dev/null +++ b/scripts/deploy-dimensionlab-website.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_NAME="${APP_NAME:-dimensionlab-website}" +SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}" +CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}" +IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}" +CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}" +PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}" +TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}" +DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}" +DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}" +DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}" + +dry_run=false +rollback_tag="" +release_tag="" +latest_tag="${IMAGE_REPO}:latest" +container_cli="" +deployment_started=false +rollback_done=false +rollback_in_progress=false + +usage() { + cat <&2 + if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then + rollback || true + fi + exit 1 +} + +run() { + if "$dry_run"; then + printf '[deploy:%s] DRY-RUN:' "$APP_NAME" + printf ' %q' "$@" + printf '\n' + return 0 + fi + + "$@" +} + +for arg in "$@"; do + case "$arg" in + --dry-run) + dry_run=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown argument: $arg" + ;; + esac +done + +deployment_ref() { + printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}" +} + +deployment_event() { + printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}" +} + +require_main_push() { + local event + local ref + + event="$(deployment_event)" + ref="$(deployment_ref)" + + if [ -n "$event" ] && [ "$event" != "push" ]; then + fail "refusing to deploy for event '$event'; production deploys only run for push" + fi + + if [ -n "$ref" ]; then + [ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main" + return 0 + fi + + local branch + branch="$(git branch --show-current 2>/dev/null || true)" + [ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main" +} + +select_container_cli() { + if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then + command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI" + container_cli="$DEPLOY_CONTAINER_CLI" + return 0 + fi + + if command -v podman >/dev/null 2>&1; then + container_cli="podman" + return 0 + fi + + if command -v docker >/dev/null 2>&1; then + container_cli="docker" + return 0 + fi + + fail "podman or docker is required" +} + +current_sha() { + if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then + printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}" + return 0 + fi + + git rev-parse HEAD +} + +tag_existing_latest_for_rollback() { + rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)" + if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then + log "tagging current latest image as $rollback_tag" + run "$container_cli" tag "$latest_tag" "$rollback_tag" + else + log "no existing $latest_tag image found; rollback image tag will not be created" + rollback_tag="" + fi +} + +container_systemd_unit() { + "$container_cli" inspect "$CONTAINER_NAME" \ + --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true +} + +require_container_managed_by_service() { + local unit + + if "$dry_run"; then + log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME" + return 0 + fi + + unit="$(container_systemd_unit)" + [ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'" +} + +validate_restart_strategy() { + case "$DEPLOY_RESTART_STRATEGY" in + systemctl) + if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then + fail "systemctl --user cannot access $SERVICE_NAME" + fi + ;; + quadlet-container|kill-container) + require_container_managed_by_service + ;; + auto) + if "$dry_run"; then + log "DRY-RUN: would validate automatic restart strategy" + elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then + require_container_managed_by_service + fi + ;; + *) + fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" + ;; + esac +} + +initialize_submodules() { + log "initializing submodules" + run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + run git submodule update --init --recursive +} + +build_image() { + local sha + local short_sha + + sha="$(current_sha)" + short_sha="${sha:0:12}" + release_tag="${IMAGE_REPO}:${short_sha}" + + [ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE" + + log "building $release_tag and $latest_tag from $CONTAINERFILE" + run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" . +} + +restart_service() { + log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY" + + case "$DEPLOY_RESTART_STRATEGY" in + systemctl) + run systemctl --user restart "$SERVICE_NAME" + ;; + quadlet-container|kill-container) + require_container_managed_by_service + run "$container_cli" stop "$CONTAINER_NAME" + ;; + auto) + if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then + run systemctl --user restart "$SERVICE_NAME" + else + require_container_managed_by_service + run "$container_cli" stop "$CONTAINER_NAME" + fi + ;; + *) + fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" + ;; + esac +} + +latest_image_id() { + "$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true +} + +container_image_id() { + "$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true +} + +container_running() { + local running + running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)" + [ "$running" = "true" ] +} + +wait_for_container_restart() { + local expected_image + + if "$dry_run"; then + log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag" + return 0 + fi + + expected_image="$(latest_image_id)" + [ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag" + + wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" +} + +wait_for_container_image() { + local expected_image="$1" + local label="$2" + local deadline + + deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS)) + + while [ "$SECONDS" -lt "$deadline" ]; do + if container_running && [ "$(container_image_id)" = "$expected_image" ]; then + log "$CONTAINER_NAME is running the $label" + return 0 + fi + + sleep 2 + done + + return 1 +} + +smoke_get() { + local url="$1" + curl -fsS --max-time 10 -o /dev/null "$url" +} + +smoke_tiles() { + local response + + response="$( + curl -fsS --max-time 20 \ + -H "content-type: application/json" \ + --data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \ + "$TILE_BATCH_URL" + )" + + [[ "$response" == *'"state":"ready"'* ]] +} + +wait_for_smoke() { + local deadline + + if "$dry_run"; then + log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL" + return 0 + fi + + deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS)) + until smoke_get "$PUBLIC_URL" && smoke_tiles; do + if [ "$SECONDS" -ge "$deadline" ]; then + fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL" + fi + + sleep 3 + done + + log "smoke checks passed" +} + +rollback() { + local rollback_image + + if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then + return 0 + fi + + rollback_done=true + rollback_in_progress=true + printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2 + rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)" + "$container_cli" tag "$rollback_tag" "$latest_tag" || true + if container_running; then + restart_service || true + else + printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2 + fi + if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then + printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2 + else + printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2 + fi + rollback_in_progress=false +} + +on_error() { + local status=$? + rollback + exit "$status" +} + +trap on_error ERR + +require_main_push +select_container_cli +validate_restart_strategy + +log "using container CLI: $container_cli" +log "target image: $latest_tag" +log "target service: $SERVICE_NAME" + +initialize_submodules +tag_existing_latest_for_rollback +build_image +deployment_started=true +restart_service +wait_for_container_restart +wait_for_smoke + +log "deployment finished" diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index c394ee9..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { renderToString } from "react-dom/server"; -import { describe, expect, test } from "vitest"; -import { AppStateView } from "./App"; - -describe("React app dashboard state view", () => { - test("renders loading dashboard state", () => { - const html = renderToString( - , - ); - - expect(html).toContain("Loading Dashboard"); - expect(html).toContain("Fetching active model"); - }); -}); diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 81ec0c3..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useEffect, useState } from "react"; -import type { DashboardRuntimeState } from "$lib/server/dashboard"; -import { - DashboardFrame, - SystemState, - dashboardDocumentToUiDashboard, - type UiSeverity, -} from "$lib/ui"; - -const loadingDashboardState: DashboardRuntimeState = { - state: "loading", - title: "Loading Dashboard", - subtitle: "Fetching active model", - message: "Waiting for the active dashboard document.", -}; - -export function AppStateView({ - dashboard, -}: { - dashboard: DashboardRuntimeState; -}) { - if (dashboard.state === "ready") { - return ( - - ); - } - - const detail = `${dashboard.subtitle}: ${dashboard.message}`; - const errors = dashboard.state === "invalid" ? dashboard.errors : []; - - return ( -
- - {errors.length ? ( -
    - {errors.map((error) => ( -
  • {error}
  • - ))} -
- ) : null} -
- ); -} - -export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): { - description: string; - title: string; -} { - if (dashboard.state === "ready") { - const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document); - return { - title: uiDashboard.title, - description: - uiDashboard.subtitle || - dashboard.document.metadata.description || - uiDashboard.title, - }; - } - - return { - title: dashboard.title, - description: dashboard.subtitle || dashboard.message, - }; -} - -export default function App() { - const [dashboard, setDashboard] = - useState(loadingDashboardState); - - useEffect(() => { - const metadata = resolveDocumentMetadata(dashboard); - document.title = metadata.title; - - let description = document.querySelector( - 'meta[name="description"]', - ); - if (!description) { - description = document.createElement("meta"); - description.name = "description"; - document.head.append(description); - } - description.content = metadata.description; - }, [dashboard]); - - useEffect(() => { - let cancelled = false; - let refreshTimer: number | undefined; - - async function loadDashboard() { - const response = await fetch("/api/dashboard"); - const nextDashboard = (await response.json()) as DashboardRuntimeState; - - if (cancelled) return; - setDashboard(nextDashboard); - - if (refreshTimer) { - window.clearInterval(refreshTimer); - refreshTimer = undefined; - } - - const refreshIntervalSeconds = - nextDashboard.state === "ready" - ? nextDashboard.document.metadata.refreshIntervalSeconds - : undefined; - - if (refreshIntervalSeconds) { - refreshTimer = window.setInterval( - () => void loadDashboard(), - refreshIntervalSeconds * 1000, - ); - } - } - - void loadDashboard(); - - return () => { - cancelled = true; - if (refreshTimer) window.clearInterval(refreshTimer); - }; - }, []); - - return ; -} - -function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { - if (state === "invalid") return "danger"; - if (state === "loading") return "loading"; - return "stale"; -} - -function stateIcon(state: DashboardRuntimeState["state"]): string { - if (state === "invalid") return "mdi:file-alert-outline"; - if (state === "loading") return "mdi:progress-clock"; - return "mdi:tray"; -} diff --git a/src/lib/components/ui/alert.tsx b/src/lib/components/ui/alert.tsx deleted file mode 100644 index fc34841..0000000 --- a/src/lib/components/ui/alert.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "$lib/utils" - -const alertVariants = cva( - "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", - className - )} - {...props} - /> - ) -} - -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/src/lib/components/ui/badge.tsx b/src/lib/components/ui/badge.tsx deleted file mode 100644 index a3d91d7..0000000 --- a/src/lib/components/ui/badge.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" - -import { cn } from "$lib/utils" - -const badgeVariants = cva( - "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - secondary: - "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", - destructive: - "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", - outline: - "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", - ghost: - "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", - link: "text-primary underline-offset-4 hover:underline", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ - className, - variant = "default", - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "span" - - return ( - - ) -} - -export { Badge, badgeVariants } diff --git a/src/lib/components/ui/button.tsx b/src/lib/components/ui/button.tsx deleted file mode 100644 index 8dda5f2..0000000 --- a/src/lib/components/ui/button.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" - -import { cn } from "$lib/utils" - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" - - return ( - - ) -} - -export { Button, buttonVariants } diff --git a/src/lib/components/ui/card.tsx b/src/lib/components/ui/card.tsx deleted file mode 100644 index bcb9e07..0000000 --- a/src/lib/components/ui/card.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from "react" - -import { cn } from "$lib/utils" - -function Card({ - className, - size = "default", - ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { - return ( -
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", - className - )} - {...props} - /> - ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} diff --git a/src/lib/components/ui/progress.tsx b/src/lib/components/ui/progress.tsx deleted file mode 100644 index 65d0a6b..0000000 --- a/src/lib/components/ui/progress.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react" -import { Progress as ProgressPrimitive } from "radix-ui" - -import { cn } from "$lib/utils" - -function Progress({ - className, - value, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { Progress } diff --git a/src/lib/components/ui/separator.tsx b/src/lib/components/ui/separator.tsx deleted file mode 100644 index 84e3c64..0000000 --- a/src/lib/components/ui/separator.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client" - -import * as React from "react" -import { Separator as SeparatorPrimitive } from "radix-ui" - -import { cn } from "$lib/utils" - -function Separator({ - className, - orientation = "horizontal", - decorative = true, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Separator } diff --git a/src/lib/components/ui/skeleton.tsx b/src/lib/components/ui/skeleton.tsx deleted file mode 100644 index 61466a8..0000000 --- a/src/lib/components/ui/skeleton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { cn } from "$lib/utils" - -function Skeleton({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { Skeleton } diff --git a/src/lib/model/fixtures/index.ts b/src/lib/model/fixtures/index.ts deleted file mode 100644 index a04278d..0000000 --- a/src/lib/model/fixtures/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { dimensionLabDashboardFixture } from "./dimensionlab"; -export { genericDashboardFixture } from "./generic"; diff --git a/src/lib/ui/components/Badge.tsx b/src/lib/ui/components/Badge.tsx deleted file mode 100644 index a21ade8..0000000 --- a/src/lib/ui/components/Badge.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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/src/lib/ui/components/Button.tsx b/src/lib/ui/components/Button.tsx deleted file mode 100644 index 8ae3b4e..0000000 --- a/src/lib/ui/components/Button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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/src/lib/ui/components/CornerBracketFrame.tsx b/src/lib/ui/components/CornerBracketFrame.tsx deleted file mode 100644 index 1dfd4f7..0000000 --- a/src/lib/ui/components/CornerBracketFrame.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/src/lib/ui/components/DashboardFrame.tsx b/src/lib/ui/components/DashboardFrame.tsx deleted file mode 100644 index e8b8fe2..0000000 --- a/src/lib/ui/components/DashboardFrame.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useId } 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 { - dashboard: UiDashboardPreview; - titleId?: string; -} - -export function DashboardFrame({ dashboard, titleId }: DashboardFrameProps) { - const generatedTitleId = useId(); - const resolvedTitleId = titleId || `${generatedTitleId}-title`; - - return ( -
-
-
- {dashboard.eyebrow ?

{dashboard.eyebrow}

: null} -

{dashboard.title}

- {dashboard.subtitle ? {dashboard.subtitle} : null} -
- {dashboard.modules.length ? ( -
- {dashboard.modules.map((module) => ( - - ))} -
- ) : null} -
- - - -
- {dashboard.serviceGroups.map((group) => ( - - ))} -
- - -
- ); -} diff --git a/src/lib/ui/components/DashboardHeader.tsx b/src/lib/ui/components/DashboardHeader.tsx deleted file mode 100644 index 68b4e11..0000000 --- a/src/lib/ui/components/DashboardHeader.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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/src/lib/ui/components/DiagonalStripeField.tsx b/src/lib/ui/components/DiagonalStripeField.tsx deleted file mode 100644 index b4c60f8..0000000 --- a/src/lib/ui/components/DiagonalStripeField.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 ( -