diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..1812091 --- /dev/null +++ b/.containerignore @@ -0,0 +1,22 @@ +.git +.svelte-kit +build +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 7752ebb..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,9 @@ dist/ data/*.sqlite data/*.sqlite-* +apps/*/data/*.sqlite +apps/*/data/*.sqlite-* coverage/ playwright-report/ test-results/ +storybook-static/ 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/README.md b/README.md index 3dadcbd..85177c5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,30 @@ # Dimension Lab Website -Standalone SvelteKit 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,13 +33,19 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence. ## Scripts -- `bun run dev`: start the local development server. -- `bun run check`: run Svelte and TypeScript checks. -- `bun run test`: run Vitest. -- `bun run build`: build the production app. -- `bun run preview`: preview the production build. -- `bun run db:generate`: generate Drizzle migrations from the server schema. -- `bun run db:check`: validate migration consistency. +- `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 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 @@ -35,20 +56,132 @@ 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. +## Seed Data + +The initial Dimension Lab dashboard lives in +`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 SvelteKit build uses the Node adapter, and the current persistence runtime -is Bun because the MVP SQLite driver is `bun:sqlite`. Later issues add the -component system, Storybook, seed data expansion, and rendering pipeline. +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 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 + +Install the Chromium browser once before running e2e checks locally: + +```sh +bunx playwright install chromium +``` + +Run the CI-ready release gate with: + +```sh +bun run test:qa +``` + +The gate runs TypeScript checks, Vitest coverage for model, +persistence, renderer, datasource mocks, and presentation boundaries, the +production build, the static Storybook build, and Playwright desktop/mobile +smoke checks against the built adapter output. Turbo owns the release task +graph; app package scripts stay as leaf commands and do not re-run the QA +pipeline internally. Playwright also performs +baseline screenshot checks, keyboard navigation checks, reduced-motion checks, +landmark checks, and axe accessibility checks against the real model-driven +route. + +Playwright uses an isolated SQLite database per run unless +`PLAYWRIGHT_DATABASE_URL` is set explicitly. + +Presentation code is checked for Dimension Lab content leakage. Environment +specific labels, links, icon names, fallback values, and datasource references +belong in validated model data, not reusable components. + +## Deployment Notes + +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 `apps/web/drizzle/` directory so startup migrations can run. + +### Internal Container + +The checked-in `apps/web/Containerfile` runs +`turbo prune @dimensionlab/web --docker`, installs the pruned manifest set, and +builds the React client plus Bun server from the pruned workspace source. For the +Dimension Lab internal host, run it behind Caddy on a loopback port and mount +persistent state at `/data`: + +```sh +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" \ + --env-file "$HOME/containers/dimensionlab-website/dimensionlab-website.env" \ + localhost/dimensionlab-website:latest +``` + +The env file must provide `AGENT_CONFIG_TOKEN`. Runtime defaults inside the +image set `HOST=0.0.0.0`, `PORT=3000`, +`DATABASE_URL=file:/data/dimensionlab.sqlite`, and +`DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle`. + +### Forgejo Actions Deployment + +Merges to `main` run `.forgejo/workflows/dimensionlab-website.yml`. Pull +requests run check, test, and build only; the deploy job is guarded to run only +for `push` events on `refs/heads/main`. + +The workflow uses two runner classes. Pull request CI runs on the containerized +`docker` runner. Production deployment runs on a separate host runner with the +`deploy:host` label so the guarded deploy script can use the user's rootless +`podman` and `systemctl --user` commands directly. The deploy job uses a +shell-only `git fetch` checkout so the host runner does not need a Node runtime +for checkout actions. + +```yaml +runner: + labels: + - deploy:host +``` + +The deploy job also performs a host preflight against the +`dimensionlab-website.service` Podman label before it builds or restarts the +production container. Do not give the general pull request runner deployment +socket access; keep deploy privileges on the dedicated `deploy` runner. diff --git a/apps/web/Containerfile b/apps/web/Containerfile 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/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..75dae71 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,22 @@ + + + + + + + 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/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..ac0fe5e --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,51 @@ +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`; + +export default defineConfig({ + testDir: "tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [["github"], ["list"]] : "list", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + 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", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 1000 }, + }, + }, + { + name: "chromium-mobile", + use: { + ...devices["Pixel 7"], + }, + }, + ], +}); 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/apps/web/src/app.css b/apps/web/src/app.css new file mode 100644 index 0000000..eeaabcb --- /dev/null +++ b/apps/web/src/app.css @@ -0,0 +1,170 @@ +@import "tailwindcss"; +@import "@dimensionlab/ui/styles.css"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root, +.dark { + --background: var(--ui-color-canvas); + --foreground: var(--ui-color-text); + --card: var(--ui-color-surface); + --card-foreground: var(--ui-color-text); + --popover: var(--ui-color-surface-raised); + --popover-foreground: var(--ui-color-text); + --primary: var(--ui-color-accent); + --primary-foreground: var(--ui-color-canvas); + --secondary: var(--ui-color-surface-raised); + --secondary-foreground: var(--ui-color-text); + --muted: var(--ui-color-surface-raised); + --muted-foreground: var(--ui-color-muted); + --accent: var(--ui-color-accent); + --accent-foreground: var(--ui-color-canvas); + --destructive: var(--ui-color-danger); + --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); + --chart-3: var(--ui-color-warning); + --chart-4: var(--ui-color-danger); + --chart-5: var(--ui-color-stale); + --radius: 0.5rem; + --sidebar: var(--ui-color-surface); + --sidebar-foreground: var(--ui-color-text); + --sidebar-primary: var(--ui-color-accent); + --sidebar-primary-foreground: var(--ui-color-canvas); + --sidebar-accent: var(--ui-color-surface-raised); + --sidebar-accent-foreground: var(--ui-color-text); + --sidebar-border: rgba(244, 244, 244, 0.16); + --sidebar-ring: var(--ui-color-accent); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +html { + background: var(--ui-color-canvas); +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + 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: auto, 40px 40px, 40px 40px, auto; + color: var(--ui-color-text); + font-family: var(--ui-font-mono); + text-rendering: geometricPrecision; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button:focus-visible, +a:focus-visible, +[tabindex]:focus-visible { + outline: 0; + box-shadow: var(--ui-focus-ring); +} + +a { + color: inherit; +} + +.state-shell { + display: grid; + min-height: 100vh; + align-content: center; + gap: var(--ui-space-3); + 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: var(--ui-color-surface-module); + color: var(--ui-color-muted); + font-size: 0.76rem; + list-style-position: inside; + padding: var(--ui-space-3); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} 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/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts new file mode 100644 index 0000000..1a97c3b --- /dev/null +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import { + dimensionLabDashboardFixture, +} from "./dimensionlab"; +import type { + DashboardDocument, + DatasourceReference, + ServiceEntry, + TelemetryCard, +} from "@dimensionlab/dashboard-model"; + +describe("Dimension Lab dashboard seed", () => { + test("defines the primary first-screen sections as model data", () => { + expect(dimensionLabDashboardFixture.metadata.title).toBe("System Overview"); + expect(dimensionLabDashboardFixture.layout.telemetry).toHaveLength(16); + expect(dimensionLabDashboardFixture.layout.serviceGroups).toEqual([ + "essentials", + "monitoring", + "ai-automation", + "systems", + "runtime-health", + ]); + expect(dimensionLabDashboardFixture.layout.statusStrips).toEqual([ + "footer-status", + ]); + expect(dimensionLabDashboardFixture.layout.modules).toEqual([ + "weather-amsterdam", + "runtime-health-summary", + ]); + }); + + test("keeps icon, link, and datasource references in seed data", () => { + const iconIds = [ + ...dimensionLabDashboardFixture.telemetry.map((card) => card.icon), + ...allServices(dimensionLabDashboardFixture).map((service) => service.icon), + ...(dimensionLabDashboardFixture.modules || []).map((module) => module.icon), + ].filter((icon): icon is string => Boolean(icon)); + + expect( + dimensionLabDashboardFixture.telemetry.every( + (card) => card.icon && card.datasource, + ), + ).toBe(true); + expect( + dimensionLabDashboardFixture.telemetry.some( + (card) => card.datasource?.type === "external" && + card.datasource.adapter === "prometheus", + ), + ).toBe(true); + expect( + allServices(dimensionLabDashboardFixture).some( + (service) => service.datasource?.type === "external" && + service.datasource.adapter === "http-status", + ), + ).toBe(true); + + for (const service of allServices(dimensionLabDashboardFixture)) { + expect(service.icon, `${service.id} must declare an Iconify id`).toBeTruthy(); + expect(verifiedSeedIconIds.has(service.icon || ""), `${service.id} icon must be verified`).toBe(true); + expect(service.datasource, `${service.id} must declare health source`).toBeTruthy(); + if (service.link) { + expect(service.link.label, `${service.id} link needs an accessible label`).toBeTruthy(); + expect(service.link.external).toBe(true); + } + } + + for (const icon of iconIds) { + expect(verifiedSeedIconIds.has(icon), `${icon} must be verified`).toBe(true); + } + }); + + test("labels fallback values and unresolved adapters explicitly", () => { + const placeholders = collectDatasources(dimensionLabDashboardFixture).filter( + (datasource) => datasource.type === "placeholder", + ); + const fallbackText = [ + ...dimensionLabDashboardFixture.telemetry.map((card) => card.detail || ""), + ...allServices(dimensionLabDashboardFixture).map((service) => service.detail || ""), + ...dimensionLabDashboardFixture.statusStrips.flatMap((strip) => + strip.items.map((item) => item.value), + ), + ...(dimensionLabDashboardFixture.modules || []).map((module) => module.detail || ""), + ].join("\n").toLowerCase(); + + expect(fallbackText).toContain("fallback"); + for (const service of allServices(dimensionLabDashboardFixture)) { + expect(service.detail?.toLowerCase(), `${service.id} detail must label fallback state`).toContain("fallback"); + } + for (const statusItem of dimensionLabDashboardFixture.statusStrips.flatMap((strip) => strip.items)) { + expect(statusItem.value.toLowerCase(), `${statusItem.id} value must label fallback state`).toContain("fallback"); + } + expect(placeholders.length).toBeGreaterThan(0); + for (const datasource of placeholders) { + expect(datasource.reason.toLowerCase()).toMatch(/fallback|pending|unresolved/); + } + }); +}); + +function allServices(document: DashboardDocument): ServiceEntry[] { + return document.serviceGroups.flatMap((group) => group.services); +} + +function collectDatasources(document: DashboardDocument): DatasourceReference[] { + const telemetry = document.telemetry + .map((card: TelemetryCard) => card.datasource) + .filter((datasource): datasource is DatasourceReference => Boolean(datasource)); + const services = allServices(document) + .map((service) => service.datasource) + .filter((datasource): datasource is DatasourceReference => Boolean(datasource)); + const modules = (document.modules || []) + .map((module) => module.datasource) + .filter((datasource): datasource is DatasourceReference => Boolean(datasource)); + + return [...telemetry, ...services, ...modules]; +} + +const verifiedSeedIconIds = new Set([ + "mdi:account-hard-hat-outline", + "mdi:backup-restore", + "mdi:brain", + "mdi:cpu-64-bit", + "mdi:docker", + "mdi:expansion-card", + "mdi:fan", + "mdi:harddisk", + "mdi:image-edit-outline", + "mdi:memory", + "mdi:pulse", + "mdi:robot-outline", + "mdi:router-network", + "mdi:thermometer", + "mdi:web", + "mdi:weather-sunny", + "simple-icons:adguard", + "simple-icons:adminer", + "simple-icons:amazonwebservices", + "simple-icons:cockpit", + "simple-icons:forgejo", + "simple-icons:grafana", + "simple-icons:n8n", + "simple-icons:ollama", + "simple-icons:postgresql", + "simple-icons:prometheus", + "simple-icons:uptimekuma", + "simple-icons:vaultwarden", + "simple-icons:wikidotjs", +]); diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.ts new file mode 100644 index 0000000..59162e6 --- /dev/null +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.ts @@ -0,0 +1,673 @@ +import { + DASHBOARD_SCHEMA_VERSION, + type DashboardDocument, + type DatasourceReference, + type MetricValue, + type ServiceEntry, + type ServiceGroup, + type Severity, + type TelemetryCard, +} from "@dimensionlab/dashboard-model"; + +type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number"; + +interface MetricSeed { + id: string; + label: string; + icon: string; + kind: NumericValueKind; + value: number; + detail: string; + severity: Severity; + datasource: DatasourceReference; + thresholds?: TelemetryCard["thresholds"]; + sparkline?: number[]; + precision?: number; +} + +interface ServiceSeed { + id: string; + label: string; + description: string; + icon: string; + datasource: DatasourceReference; + href?: string; + severity?: Severity; + detail?: string; +} + +const REAL_FILESYSTEM_FILTER = + 'fstype!~"tmpfs|overlay|squashfs|nsfs|tracefs|autofs|proc|sysfs|cgroup2|devtmpfs|securityfs|debugfs|pstore|bpf|configfs|selinuxfs|mqueue|hugetlbfs|fusectl|ramfs"'; +const USER_MOUNT_FILTER = + 'mountpoint=~"^/home($|/)|^/srv($|/)|^/data($|/)|^/mnt($|/)|^/media($|/)",mountpoint!~"^/(boot|boot/efi|efi|var|usr|opt|run|dev|proc|sys)($|/)"'; +const SYSTEM_MOUNT_FILTER = + 'mountpoint=~"^/$|^/boot($|/)|^/boot/efi$|^/var($|/)|^/usr($|/)|^/opt($|/)",mountpoint!~"^/home($|/)|^/srv($|/)|^/data($|/)|^/mnt($|/)|^/media($|/)"'; + +const diskUsedQuery = (mountFilter: string) => { + const selector = `job="node",${REAL_FILESYSTEM_FILTER},${mountFilter}`; + return `topk(1, max by (host, mountpoint) (100 * (1 - node_filesystem_avail_bytes{${selector}} / node_filesystem_size_bytes{${selector}})))`; +}; +const ramQuery = (host: string) => + `100 * (1 - node_memory_MemAvailable_bytes{job="node",host="${host}"} / node_memory_MemTotal_bytes{job="node",host="${host}"})`; +const gpuQuery = (name: string, metric: string) => + `${metric}{job="node",name=~".*${name}.*"}`; +const gpuVramQuery = (name: string) => + `100 * nvidia_gpu_memory_used_bytes{job="node",name=~".*${name}.*"} / nvidia_gpu_memory_total_bytes{job="node",name=~".*${name}.*"}`; +const hostCpuQuery = + 'topk(1, 100 * (1 - avg by (host) (rate(node_cpu_seconds_total{job="node",mode="idle"}[5m]))))'; +const topCpuQuery = + 'topk(1, 100 * rate(podman_container_cpu_seconds_total{job=~"podman-.*"}[5m]) * on(host,id) group_left(name) podman_container_info{job=~"podman-.*"})'; +const topRamQuery = + 'topk(1, podman_container_mem_usage_bytes{job=~"podman-.*"} * on(host,id) group_left(name) podman_container_info{job=~"podman-.*"})'; + +export const dimensionLabDashboardFixture: DashboardDocument = { + schemaVersion: DASHBOARD_SCHEMA_VERSION, + metadata: { + title: "System Overview", + subtitle: "Capacity, noise & top consumers", + description: + "Initial Dimension Lab dashboard seed data. Runtime values are fallback values until datasource adapters are enabled.", + timezone: "Europe/Amsterdam", + refreshIntervalSeconds: 15, + }, + layout: { + density: "dense", + telemetry: [ + "infra-ram", + "gpu-host-ram", + "network-ram", + "user-disk-peak", + "system-disk-peak", + "peak-cpu-busy", + "top-cpu-container", + "top-ram-container", + "gpu-3060-load", + "gpu-3060-vram", + "gpu-3060-temp", + "gpu-3060-fan", + "gpu-3090-load", + "gpu-3090-vram", + "gpu-3090-temp", + "gpu-3090-fan", + ], + serviceGroups: ["essentials", "monitoring", "ai-automation", "systems", "runtime-health"], + statusStrips: ["footer-status"], + modules: ["weather-amsterdam", "runtime-health-summary"], + }, + telemetry: [ + metric({ + id: "infra-ram", + label: "Infra RAM", + icon: "mdi:memory", + kind: "percent", + value: 19, + detail: "fallback - linux-infra memory used", + severity: "ok", + thresholds: percentThresholds(), + datasource: prometheus( + ramQuery("linux-infra"), + ), + sparkline: [18, 18, 19, 19, 18, 19], + }), + metric({ + id: "gpu-host-ram", + label: "GPU Host RAM", + icon: "mdi:memory", + kind: "percent", + value: 17, + detail: "fallback - GPU host memory used", + severity: "ok", + thresholds: percentThresholds(), + datasource: prometheus( + ramQuery("linux"), + ), + sparkline: [16, 16, 17, 17, 17, 17], + }), + metric({ + id: "network-ram", + label: "Network RAM", + icon: "mdi:router-network", + kind: "percent", + value: 5, + detail: "fallback - network core memory used", + severity: "ok", + thresholds: percentThresholds(), + datasource: prometheus( + ramQuery("network-core"), + ), + sparkline: [5, 5, 5, 6, 5, 5], + }), + metric({ + id: "user-disk-peak", + label: "User Disk Peak", + icon: "mdi:harddisk", + kind: "percent", + value: 29, + detail: "fallback - /home peak usage", + severity: "ok", + thresholds: percentThresholds(80, 92), + datasource: prometheus( + diskUsedQuery(USER_MOUNT_FILTER), + ), + sparkline: [27, 27, 28, 29, 29, 29], + }), + metric({ + id: "system-disk-peak", + label: "System Disk Peak", + icon: "mdi:harddisk", + kind: "percent", + value: 49, + detail: "fallback - /boot peak usage", + severity: "ok", + thresholds: percentThresholds(80, 92), + datasource: prometheus( + diskUsedQuery(SYSTEM_MOUNT_FILTER), + ), + sparkline: [48, 48, 49, 49, 49, 49], + }), + metric({ + id: "peak-cpu-busy", + label: "Peak CPU Busy", + icon: "mdi:cpu-64-bit", + kind: "percent", + value: 3, + detail: "fallback - linux-infra 5m CPU busy", + severity: "ok", + thresholds: percentThresholds(75, 90), + datasource: prometheus( + hostCpuQuery, + ), + sparkline: [2, 3, 3, 4, 3, 3], + }), + metric({ + id: "top-cpu-container", + label: "Top CPU Container", + icon: "mdi:docker", + kind: "percent", + value: 7.9, + precision: 1, + detail: "fallback - top container CPU pending label mapping", + severity: "ok", + thresholds: percentThresholds(70, 90), + datasource: prometheus(topCpuQuery), + sparkline: [6.1, 6.4, 7.0, 7.5, 7.2, 7.9], + }), + metric({ + id: "top-ram-container", + label: "Top RAM Container", + icon: "mdi:docker", + kind: "bytes", + value: Math.round(5.2 * 1024 ** 3), + precision: 1, + detail: "fallback - top container RAM pending label mapping", + severity: "danger", + thresholds: { warning: 3 * 1024 ** 3, danger: 5 * 1024 ** 3 }, + datasource: prometheus(topRamQuery), + sparkline: [ + 3.1 * 1024 ** 3, + 3.5 * 1024 ** 3, + 4.1 * 1024 ** 3, + 4.8 * 1024 ** 3, + 5.0 * 1024 ** 3, + 5.2 * 1024 ** 3, + ], + }), + metric({ + id: "gpu-3060-load", + label: "3060 GPU Load", + icon: "mdi:expansion-card", + kind: "percent", + value: 0, + detail: "fallback - RTX 3060 GPU utilization", + severity: "ok", + thresholds: percentThresholds(85, 95), + datasource: prometheus(gpuQuery("3060", "nvidia_gpu_utilization_percent")), + sparkline: [0, 0, 0, 0, 0, 0], + }), + metric({ + id: "gpu-3060-vram", + label: "3060 VRAM", + icon: "mdi:memory", + kind: "percent", + value: 0, + detail: "fallback - RTX 3060 VRAM used", + severity: "ok", + thresholds: percentThresholds(80, 92), + datasource: prometheus( + gpuVramQuery("3060"), + ), + sparkline: [0, 0, 0, 0, 0, 0], + }), + metric({ + id: "gpu-3060-temp", + label: "3060 Temp", + icon: "mdi:thermometer", + kind: "temperature", + value: 54, + detail: "fallback - RTX 3060 temperature", + severity: "ok", + thresholds: { warning: 75, danger: 85 }, + datasource: prometheus(gpuQuery("3060", "nvidia_gpu_temperature_celsius")), + sparkline: [52, 53, 54, 54, 53, 54], + }), + metric({ + id: "gpu-3060-fan", + label: "3060 Fan Spin", + icon: "mdi:fan", + kind: "percent", + value: 0, + detail: "fallback - RTX 3060 fan duty", + severity: "ok", + thresholds: percentThresholds(80, 95), + datasource: prometheus(gpuQuery("3060", "nvidia_gpu_fan_speed_percent")), + sparkline: [0, 0, 0, 0, 0, 0], + }), + metric({ + id: "gpu-3090-load", + label: "3090 GPU Load", + icon: "mdi:expansion-card", + kind: "percent", + value: 0, + detail: "fallback - RTX 3090 GPU utilization", + severity: "ok", + thresholds: percentThresholds(85, 95), + datasource: prometheus(gpuQuery("3090", "nvidia_gpu_utilization_percent")), + sparkline: [0, 0, 0, 0, 0, 0], + }), + metric({ + id: "gpu-3090-vram", + label: "3090 VRAM", + icon: "mdi:memory", + kind: "percent", + value: 74, + detail: "fallback - RTX 3090 VRAM used", + severity: "warning", + thresholds: percentThresholds(70, 90), + datasource: prometheus( + gpuVramQuery("3090"), + ), + sparkline: [68, 70, 72, 74, 73, 74], + }), + metric({ + id: "gpu-3090-temp", + label: "3090 Temp", + icon: "mdi:thermometer", + kind: "temperature", + value: 50, + detail: "fallback - RTX 3090 temperature", + severity: "ok", + thresholds: { warning: 75, danger: 85 }, + datasource: prometheus(gpuQuery("3090", "nvidia_gpu_temperature_celsius")), + sparkline: [49, 50, 50, 51, 50, 50], + }), + metric({ + id: "gpu-3090-fan", + label: "3090 Fan Spin", + icon: "mdi:fan", + kind: "percent", + value: 0, + detail: "fallback - RTX 3090 fan duty", + severity: "ok", + thresholds: percentThresholds(80, 95), + datasource: prometheus(gpuQuery("3090", "nvidia_gpu_fan_speed_percent")), + sparkline: [0, 0, 0, 0, 0, 0], + }), + ], + serviceGroups: [ + group("essentials", "Essentials", [ + service({ + id: "vaultwarden", + label: "Vaultwarden", + description: "Password manager", + icon: "simple-icons:vaultwarden", + href: "https://vault.dimensionlab.net", + datasource: uptimeMonitor(1), + }), + service({ + id: "forgejo", + label: "Forgejo", + description: "Git repositories", + icon: "simple-icons:forgejo", + href: "https://git.dimensionlab.net", + datasource: uptimeMonitor(2), + }), + service({ + id: "wiki", + label: "Wiki", + description: "Internal documentation", + icon: "simple-icons:wikidotjs", + href: "https://wiki.dimensionlab.net", + datasource: uptimeMonitor(3), + }), + service({ + id: "aws-start", + label: "AWS Start", + description: "AWS access portal", + icon: "simple-icons:amazonwebservices", + href: "https://dimensionlab.awsapps.com/start", + datasource: uptimeMonitor(21), + }), + service({ + id: "adguard-primary", + label: "AdGuard Primary", + description: "DNS filtering and DHCP", + icon: "simple-icons:adguard", + href: "https://control.dimensionlab.net", + datasource: uptimeMonitor(14), + }), + service({ + id: "adguard-secondary", + label: "AdGuard Secondary", + description: "Fallback DNS", + icon: "simple-icons:adguard", + href: "https://control-secondary.dimensionlab.net", + datasource: uptimeMonitor(18), + }), + ]), + group("monitoring", "Monitoring", [ + service({ + id: "grafana", + label: "Grafana", + description: "Capacity and noise dashboard", + icon: "simple-icons:grafana", + href: "https://grafana.dimensionlab.net", + datasource: uptimeMonitor(11), + }), + service({ + id: "uptime-kuma", + label: "Uptime Kuma", + description: "Service uptime checks", + icon: "simple-icons:uptimekuma", + href: "https://uptime.dimensionlab.net", + datasource: uptimeMonitor(10), + }), + service({ + id: "prometheus", + label: "Prometheus", + description: "Metrics database", + icon: "simple-icons:prometheus", + href: "https://prometheus.dimensionlab.net", + datasource: uptimeMonitor(12), + }), + service({ + id: "backrest", + label: "Backrest", + description: "Restic backup manager", + icon: "mdi:backup-restore", + href: "https://backups.dimensionlab.net", + datasource: uptimeMonitor(13), + }), + ]), + group("ai-automation", "AI & Automation", [ + service({ + id: "n8n", + label: "n8n", + description: "Workflow automation", + icon: "simple-icons:n8n", + href: "https://workflows.dimensionlab.net", + datasource: uptimeMonitor(4), + }), + service({ + id: "open-webui", + label: "Open WebUI", + description: "Chat and model interface", + icon: "mdi:web", + href: "https://webui.dimensionlab.net", + datasource: uptimeMonitor(5), + }), + service({ + id: "comfyui", + label: "ComfyUI", + description: "Image generation workflows", + icon: "mdi:image-edit-outline", + href: "https://comfy.dimensionlab.net", + datasource: uptimeMonitor(6), + }), + service({ + id: "models", + label: "Models", + description: "Local model management", + icon: "mdi:brain", + href: "https://models.dimensionlab.net", + datasource: uptimeMonitor(7), + }), + ]), + group("systems", "Systems", [ + service({ + id: "adminer", + label: "Adminer", + description: "PostgreSQL database browser", + icon: "simple-icons:adminer", + href: "https://db.dimensionlab.net", + datasource: uptimeMonitor(17), + }), + service({ + id: "assistant", + label: "Assistant", + description: "Personal AI agent gateway", + icon: "mdi:robot-outline", + href: "https://assistant.dimensionlab.net", + datasource: uptimeMonitor(8), + }), + service({ + id: "suna", + label: "Suna", + description: "AI command center", + icon: "mdi:account-hard-hat-outline", + href: "https://suna.dimensionlab.net", + datasource: uptimeMonitor(9), + }), + service({ + id: "cockpit-infra", + label: "Cockpit Infra", + description: "linux-infra server console", + icon: "simple-icons:cockpit", + href: "https://infra-cockpit.dimensionlab.net", + datasource: uptimeMonitor(15), + }), + service({ + id: "cockpit-gpu", + label: "Cockpit GPU", + description: "Linux GPU server console", + icon: "simple-icons:cockpit", + href: "https://linux-cockpit.dimensionlab.net", + datasource: uptimeMonitor(16), + }), + service({ + id: "cockpit-network-core", + label: "Cockpit Network Core", + description: "i3 NUC DNS/DHCP console", + icon: "simple-icons:cockpit", + href: "https://network-core.dimensionlab.net", + datasource: uptimeMonitor(19), + }), + ]), + group("runtime-health", "Runtime Health", [ + service({ + id: "forgejo-ssh-relay", + label: "Forgejo SSH Relay", + description: "Public Git SSH relay", + icon: "simple-icons:forgejo", + href: "https://uptime.dimensionlab.net/status/dimensionlab", + datasource: uptimeMonitor(28), + detail: "fallback - uptime monitor pending", + }), + service({ + id: "postgresql", + label: "PostgreSQL", + description: "Shared application database", + icon: "simple-icons:postgresql", + datasource: uptimeMonitor(22), + detail: "fallback - postgres exporter pending", + }), + service({ + id: "ollama-api", + label: "Ollama API", + description: "Local model API", + icon: "simple-icons:ollama", + datasource: uptimeMonitor(23), + detail: "fallback - internal health check pending", + }), + service({ + id: "node-exporter", + label: "Node Exporter", + description: "Host metrics exporter", + icon: "simple-icons:prometheus", + datasource: uptimeMonitor(24), + detail: "fallback - exporter health from Uptime Kuma", + }), + service({ + id: "podman-user-exporter", + label: "Podman User Exporter", + description: "Rootless container metrics", + icon: "simple-icons:prometheus", + datasource: uptimeMonitor(25), + detail: "fallback - exporter health from Uptime Kuma", + }), + service({ + id: "podman-system-exporter", + label: "Podman System Exporter", + description: "System container metrics", + icon: "simple-icons:prometheus", + datasource: uptimeMonitor(26), + detail: "fallback - exporter health from Uptime Kuma", + }), + service({ + id: "network-core-node-exporter", + label: "Network Core Node Exporter", + description: "i3 DNS/DHCP metrics", + icon: "simple-icons:prometheus", + datasource: uptimeMonitor(27), + detail: "fallback - exporter health from Prometheus", + }), + ], "grid"), + ], + statusStrips: [ + { + id: "footer-status", + items: [ + { id: "system-status", label: "System Status", value: "Fallback operational", severity: "ok" }, + { id: "last-sync", label: "Last Sync", value: "fallback: 2 minutes ago", severity: "stale" }, + { id: "uptime", label: "Uptime", value: "fallback: 28d 14h 32m", severity: "ok" }, + { id: "load-avg", label: "Load Avg", value: "fallback: 0.47 0.53 0.59", severity: "neutral" }, + { id: "auto-refresh", label: "Auto Refresh", value: "fallback: 15s", severity: "neutral" }, + ], + }, + ], + modules: [ + { + id: "weather-amsterdam", + kind: "weather", + title: "Amsterdam", + value: "28.3 C", + detail: "fallback - weather adapter pending", + icon: "mdi:weather-sunny", + severity: "ok", + datasource: { + type: "external", + adapter: "weather", + reference: "open-meteo:latitude=52.3676&longitude=4.9041", + }, + }, + { + id: "runtime-health-summary", + kind: "summary", + title: "Runtime Health", + value: "fallback", + detail: "fallback - health adapters pending", + icon: "mdi:pulse", + severity: "stale", + datasource: placeholder("summary pending live health aggregation"), + }, + ], +}; + +function metric(seed: MetricSeed): TelemetryCard { + return { + id: seed.id, + label: seed.label, + icon: seed.icon, + value: metricValue(seed.kind, seed.value, seed.precision), + detail: seed.detail, + severity: seed.severity, + thresholds: seed.thresholds, + datasource: seed.datasource, + sparkline: seed.sparkline, + }; +} + +function metricValue( + kind: NumericValueKind, + value: number, + precision?: number, +): MetricValue { + return precision === undefined ? { kind, value } : { kind, value, precision }; +} + +function service(seed: ServiceSeed): ServiceEntry { + const entry: ServiceEntry = { + id: seed.id, + label: seed.label, + description: seed.description, + icon: seed.icon, + severity: seed.severity || "ok", + detail: seed.detail || "fallback - health check pending", + datasource: seed.datasource, + }; + + if (!seed.href) return entry; + + return { + ...entry, + link: { + href: seed.href, + label: `Open ${seed.label}`, + external: true, + }, + }; +} + +function group( + id: string, + title: string, + services: ServiceEntry[], + layout: ServiceGroup["layout"] = "list", +): ServiceGroup { + return { + id, + layout, + services, + title, + }; +} + +function percentThresholds(warning = 80, danger = 92) { + return { warning, danger }; +} + +function prometheus(reference: string): DatasourceReference { + return { + type: "external", + adapter: "prometheus", + reference, + }; +} + +function httpStatus(url: string): DatasourceReference { + return { + type: "external", + adapter: "http-status", + reference: `GET ${url}`, + }; +} + +function placeholder(reason: string): DatasourceReference { + return { + type: "placeholder", + reason, + }; +} + +function uptimeMonitor(id: number): DatasourceReference { + return httpStatus(`https://uptime.dimensionlab.net/_homepage-badge/${id}`); +} diff --git a/apps/web/src/lib/presentation-boundary.test.ts b/apps/web/src/lib/presentation-boundary.test.ts new file mode 100644 index 0000000..8a62205 --- /dev/null +++ b/apps/web/src/lib/presentation-boundary.test.ts @@ -0,0 +1,80 @@ +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(repoRoot, "packages", "ui", "src"), + join(appRoot, "src", "App.tsx"), + join(appRoot, "src", "app.css"), + join(appRoot, "src", "lib", "ui-adapter"), +]; + +const forbiddenTerms = [ + "dimensionlab", + "dimension lab", + "vaultwarden", + "forgejo", + "grafana", + "uptime kuma", + "prometheus", + "backrest", + "open webui", + "comfyui", + "adminer", + "cockpit", + "ollama", + "dimensionlab.net", +]; + +describe("presentation content boundary", () => { + test("keeps environment-specific content out of route and UI implementation", () => { + const source = withoutInternalPackageScope( + presentationRoots.map(readPresentationSource).join("\n").toLowerCase(), + ); + + expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]); + }); + + test("does not keep legacy presentation component files in the React runtime", () => { + const legacyExtension = [".sve", "lte"].join(""); + + expect(findFiles(join(appRoot, "src"), legacyExtension)).toEqual([]); + }); +}); + +function readPresentationSource(path: string): string { + if (!existsSync(path)) return ""; + + const stats = statSync(path); + if (stats.isFile()) { + if (path.endsWith(".test.ts")) return ""; + if (path.includes(`${join("packages", "ui", "src", "stories")}${"/"}`)) { + return ""; + } + if (!/\.(tsx|ts|js|mjs|css|json)$/.test(path)) return ""; + return readFileSync(path, "utf8"); + } + + return readdirSync(path) + .map((entry) => readPresentationSource(join(path, entry))) + .join("\n"); +} + +function findFiles(path: string, extension: string): string[] { + const stats = statSync(path); + if (stats.isFile()) return path.endsWith(extension) ? [path] : []; + + 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/apps/web/src/lib/server/agent-config/agent-config.test.ts b/apps/web/src/lib/server/agent-config/agent-config.test.ts new file mode 100644 index 0000000..cdc60cf --- /dev/null +++ b/apps/web/src/lib/server/agent-config/agent-config.test.ts @@ -0,0 +1,595 @@ +import { existsSync, rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store"; +import { + AgentConfigAuthorizationError, + authorizeAgentConfigRequest, + handleAgentDashboardRequest, + previewDashboardChanges, + publishDashboardChanges, + rollbackDashboardRevision, + type AgentDashboardOperation, + type JsonPatchOperation, +} from "."; + +const stores: DashboardStore[] = []; +const tempRoots: string[] = []; + +afterEach(() => { + stores.splice(0).forEach((store) => store.close()); + tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true })); +}); + +describe("agent dashboard configuration API", () => { + test("previews typed dashboard operations with an RFC 6902-compatible patch", () => { + const operations = exampleOperations(); + + const result = previewDashboardChanges(genericDashboardFixture, operations); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected preview success"); + expect(result.document.serviceGroups[0]?.id).toBe("edge"); + expect(result.document.serviceGroups[0]?.services[0]).toMatchObject({ + id: "edge-router", + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://edge.example.test/api/status", + }, + }); + expect(result.document.layout.telemetry).toEqual([ + "service-uptime", + "edge-latency", + "queue-depth", + ]); + expect(genericDashboardFixture.serviceGroups.map((group) => group.id)).toEqual([ + "core-services", + ]); + expect(result.patch.length).toBeGreaterThan(0); + expect(result.patch.every((operation) => operation.path.startsWith("/"))).toBe(true); + expect(result.patch.map((operation) => operation.op)).toContain("add"); + }); + + test("returns structured repairable errors for invalid operations", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "add_service", + groupId: "missing-group", + service: exampleService(), + }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected preview failure"); + expect(result.errors).toEqual([ + expect.objectContaining({ + code: "group_not_found", + operationIndex: 0, + path: "/serviceGroups", + }), + ]); + }); + + test("rejects unsupported target-specific mutations", () => { + const datasourceResult = previewDashboardChanges(genericDashboardFixture, [ + { + type: "connect_datasource", + target: { kind: "statusItem", stripId: "runtime", id: "status" }, + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://status.example.test/api", + }, + }, + ]); + expect(datasourceResult.ok).toBe(false); + if (datasourceResult.ok) throw new Error("expected datasource failure"); + expect(datasourceResult.errors[0]).toMatchObject({ + code: "unsupported_target", + operationIndex: 0, + path: "/statusStrips", + }); + + const thresholdResult = previewDashboardChanges(genericDashboardFixture, [ + { + type: "set_status_rule", + target: { kind: "service", id: "identity" }, + thresholds: { warning: 1 }, + }, + ]); + expect(thresholdResult.ok).toBe(false); + if (thresholdResult.ok) throw new Error("expected threshold failure"); + expect(thresholdResult.errors[0]).toMatchObject({ + code: "unsupported_target", + operationIndex: 0, + path: "/serviceGroups", + }); + }); + + test("removes status strip items through the shared remove operation", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "remove_item", + target: { kind: "statusItem", stripId: "runtime", id: "sync" }, + }, + ]); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected remove success"); + expect(result.document.statusStrips[0]?.items.map((item) => item.id)).toEqual([ + "status", + ]); + expect(result.patch.map((operation) => operation.op)).toContain("remove"); + }); + + test("returns RFC 6902-applicable patches for multiple array removals", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "remove_item", + target: { kind: "statusItem", stripId: "runtime", id: "status" }, + }, + { + type: "remove_item", + target: { kind: "statusItem", stripId: "runtime", id: "sync" }, + }, + ]); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected remove success"); + expect( + result.patch + .filter((operation) => operation.op === "remove") + .map((operation) => operation.path), + ).toEqual(["/statusStrips/0/items/1", "/statusStrips/0/items/0"]); + expect(applyJsonPatch(genericDashboardFixture, result.patch)).toMatchObject({ + statusStrips: [{ items: [] }], + }); + }); + + test("rejects ambiguous service and status targets", () => { + const duplicateDocument = documentWithDuplicateNestedIds(); + + const connect = previewDashboardChanges(duplicateDocument, [ + { + type: "connect_datasource", + target: { kind: "service", id: "identity" }, + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://identity.example.test/health", + }, + }, + ]); + expect(connect.ok).toBe(false); + if (connect.ok) throw new Error("expected ambiguous service failure"); + expect(connect.errors[0]).toMatchObject({ + code: "ambiguous_target", + operationIndex: 0, + path: "/serviceGroups", + }); + + const remove = previewDashboardChanges(duplicateDocument, [ + { + type: "remove_item", + target: { kind: "service", id: "identity" }, + }, + ]); + expect(remove.ok).toBe(false); + if (remove.ok) throw new Error("expected ambiguous removal failure"); + expect(remove.errors[0]).toMatchObject({ + code: "ambiguous_target", + operationIndex: 0, + path: "/serviceGroups", + }); + + const status = previewDashboardChanges(duplicateDocument, [ + { + type: "set_status_rule", + target: { kind: "statusItem", id: "status" }, + value: "Healthy", + }, + ]); + expect(status.ok).toBe(false); + if (status.ok) throw new Error("expected ambiguous status failure"); + expect(status.errors[0]).toMatchObject({ + code: "ambiguous_target", + operationIndex: 0, + path: "/statusStrips", + }); + }); + + test("rejects create_dashboard when it is not the first operation", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "remove_item", + target: { kind: "telemetry", id: "queue-depth" }, + }, + { + type: "create_dashboard", + document: genericDashboardFixture, + }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected sequence failure"); + expect(result.errors[0]).toMatchObject({ + code: "invalid_operation_sequence", + operationIndex: 1, + path: "/1", + }); + }); + + test("publishes valid operations as a persisted dashboard revision", async () => { + const { dbPath, store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + message: "initial dashboard", + }); + + const result = publishDashboardChanges(store, exampleOperations(), { + actor: "agent", + message: "add edge router", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected publish success"); + expect(existsSync(dbPath)).toBe(true); + expect(result.revision.operation).toBe("commit"); + expect(result.revision.actor).toBe("agent"); + expect(result.revision.message).toBe("add edge router"); + expect(result.previousRevisionId).toBe(seed.id); + expect(result.patch.length).toBeGreaterThan(0); + expect(store.getActiveDashboard()?.document.serviceGroups[0]?.id).toBe("edge"); + expect(store.listRevisions()).toHaveLength(2); + }); + + test("does not publish invalid operations", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + + const result = publishDashboardChanges(store, [ + { + type: "remove_item", + target: { kind: "telemetry", id: "missing-metric" }, + }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected publish failure"); + expect(result.errors[0]).toMatchObject({ + code: "item_not_found", + operationIndex: 0, + }); + expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id); + expect(store.listRevisions()).toHaveLength(1); + }); + + test("rolls back through the agent-safe revision path", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + const publish = publishDashboardChanges(store, exampleOperations(), { + actor: "agent", + }); + expect(publish.ok).toBe(true); + + const rollback = rollbackDashboardRevision(store, seed.id, { + actor: "agent", + message: "restore previous dashboard", + }); + + expect(rollback.operation).toBe("rollback"); + expect(rollback.actor).toBe("agent"); + expect(rollback.sourceRevisionId).toBe(seed.id); + expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console"); + expect(store.listRevisions().map((revision) => revision.operation)).toEqual([ + "rollback", + "commit", + "seed", + ]); + }); + + test("authenticates agent configuration requests with a shared token", () => { + const authorized = new Request("https://dimensionlab.test/api/agent/dashboard", { + headers: { authorization: "Bearer shared-secret" }, + }); + const rejected = new Request("https://dimensionlab.test/api/agent/dashboard"); + + expect(authorizeAgentConfigRequest(authorized, "shared-secret")).toEqual({ + ok: true, + }); + expect(() => authorizeAgentConfigRequest(rejected, "shared-secret")).toThrow( + AgentConfigAuthorizationError, + ); + expect(() => authorizeAgentConfigRequest(authorized, "")).toThrow( + AgentConfigAuthorizationError, + ); + }); + + test("handles preview, publish, and rollback requests through the HTTP adapter", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + + const preview = await handleAgentDashboardRequest( + jsonRequest({ + action: "preview_changes", + operations: exampleOperations(), + }), + { store, token: "shared-secret" }, + ); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ + ok: true, + action: "preview_changes", + patch: expect.any(Array), + }); + expect(store.listRevisions()).toHaveLength(1); + + const publish = await handleAgentDashboardRequest( + jsonRequest({ + action: "publish_changes", + actor: "agent", + message: "publish edge router", + operations: exampleOperations(), + }), + { store, token: "shared-secret" }, + ); + expect(publish.status).toBe(200); + expect(await publish.json()).toMatchObject({ + ok: true, + action: "publish_changes", + revision: { operation: "commit", actor: "agent" }, + }); + expect(store.listRevisions()).toHaveLength(2); + + const rollback = await handleAgentDashboardRequest( + jsonRequest({ + action: "rollback_revision", + actor: "agent", + revisionId: seed.id, + }), + { store, token: "shared-secret" }, + ); + expect(rollback.status).toBe(200); + expect(await rollback.json()).toMatchObject({ + ok: true, + action: "rollback_revision", + revision: { operation: "rollback", sourceRevisionId: seed.id }, + }); + }); + + test("returns a structured error for unknown rollback revisions", async () => { + const { store } = await createTestStore(); + store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + + const rollback = await handleAgentDashboardRequest( + jsonRequest({ + action: "rollback_revision", + actor: "agent", + revisionId: "missing-revision", + }), + { store, token: "shared-secret" }, + ); + + expect(rollback.status).toBe(404); + expect(await rollback.json()).toMatchObject({ + ok: false, + errors: [ + { + code: "revision_not_found", + path: "/revisionId", + }, + ], + }); + }); + + test("previews create_dashboard requests against an empty store", async () => { + const { store } = await createTestStore(); + + const preview = await handleAgentDashboardRequest( + jsonRequest({ + action: "preview_changes", + operations: [ + { + type: "create_dashboard", + document: genericDashboardFixture, + }, + ], + }), + { store, token: "shared-secret" }, + ); + + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ + ok: true, + action: "preview_changes", + document: { + metadata: { + title: "Operations Console", + }, + }, + }); + expect(store.listRevisions()).toHaveLength(0); + }); +}); + +async function createTestStore() { + const root = await mkdtemp(join(tmpdir(), "dimensionlab-agent-config-")); + tempRoots.push(root); + const dbPath = join(root, "dashboard.sqlite"); + const store = createDashboardStore({ + databaseUrl: `file:${dbPath}`, + }); + stores.push(store); + + return { dbPath, store }; +} + +function exampleOperations(): AgentDashboardOperation[] { + return [ + { + type: "add_section", + section: { + id: "edge", + title: "Edge", + layout: "list", + }, + }, + { + type: "add_service", + groupId: "edge", + service: exampleService(), + }, + { + type: "add_metric_card", + card: { + id: "edge-latency", + label: "Edge Latency", + value: { kind: "latency", value: 12, precision: 0 }, + severity: "ok", + detail: "p95", + datasource: { + type: "external", + adapter: "prometheus", + reference: "histogram_quantile(0.95, edge_request_duration_seconds_bucket)", + }, + }, + position: { afterId: "service-uptime" }, + }, + { + type: "connect_datasource", + target: { + kind: "service", + groupId: "edge", + id: "edge-router", + }, + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://edge.example.test/api/status", + }, + }, + { + type: "set_status_rule", + target: { kind: "telemetry", id: "edge-latency" }, + thresholds: { warning: 50, danger: 100 }, + }, + { + type: "arrange_item", + area: "serviceGroups", + id: "edge", + index: 0, + }, + ]; +} + +function exampleService() { + return { + id: "edge-router", + label: "Edge Router", + description: "Ingress and routing", + icon: "mdi:router-network", + severity: "ok" as const, + detail: "pending datasource", + datasource: { type: "placeholder" as const, reason: "health adapter pending" }, + link: { + href: "https://edge.example.test", + label: "Open Edge Router", + external: true, + }, + }; +} + +function documentWithDuplicateNestedIds(): DashboardDocument { + const document = structuredClone(genericDashboardFixture); + document.layout.serviceGroups.push("secondary-services"); + document.serviceGroups.push({ + id: "secondary-services", + title: "Secondary Services", + layout: "list", + services: [ + { + ...document.serviceGroups[0].services[0], + label: "Shadow Identity", + }, + ], + }); + document.layout.statusStrips.push("secondary-runtime"); + document.statusStrips.push({ + id: "secondary-runtime", + items: [ + { + ...document.statusStrips[0].items[0], + value: "Healthy", + }, + ], + }); + return document; +} + +function applyJsonPatch(value: T, patch: JsonPatchOperation[]): T { + const next = structuredClone(value); + for (const operation of patch) { + const { parent, key } = jsonPointerTarget(next, operation.path); + if (operation.op === "remove") { + if (Array.isArray(parent)) { + parent.splice(Number(key), 1); + } else { + delete parent[key]; + } + } else if (operation.op === "add") { + if (Array.isArray(parent)) { + parent.splice(Number(key), 0, operation.value); + } else { + parent[key] = operation.value; + } + } else if (operation.op === "replace") { + if (Array.isArray(parent)) { + parent[Number(key)] = operation.value; + } else { + parent[key] = operation.value; + } + } + } + return next; +} + +function jsonPointerTarget(value: unknown, path: string) { + const segments = path + .split("/") + .slice(1) + .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")); + const key = segments.pop(); + if (key === undefined) throw new Error(`Invalid JSON pointer: ${path}`); + + let parent = value as Record | unknown[]; + for (const segment of segments) { + parent = Array.isArray(parent) + ? (parent[Number(segment)] as Record | unknown[]) + : (parent[segment] as Record | unknown[]); + } + return { parent, key }; +} + +function jsonRequest(body: unknown) { + return new Request("https://dimensionlab.test/api/agent/dashboard", { + body: JSON.stringify(body), + headers: { + authorization: "Bearer shared-secret", + "content-type": "application/json", + }, + method: "POST", + }); +} diff --git a/apps/web/src/lib/server/agent-config/index.ts b/apps/web/src/lib/server/agent-config/index.ts new file mode 100644 index 0000000..ab3d0ae --- /dev/null +++ b/apps/web/src/lib/server/agent-config/index.ts @@ -0,0 +1,1292 @@ +import Ajv, { type ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; +import { Type, type Static } from "@sinclair/typebox"; +import { + DashboardDocumentSchema, + DatasourceReferenceSchema, + ServiceEntrySchema, + TelemetryCardSchema, + ThresholdSchema, + validateDashboardDocument, + type DashboardDocument, + type DashboardModule, + type DashboardValidationFailure, + type DatasourceReference, + type ServiceEntry, + type ServiceGroup, + type StatusItem, + type TelemetryCard, +} from "@dimensionlab/dashboard-model"; +import { + createDashboardStore, + DashboardRevisionNotFoundError, + type DashboardRevision, + type DashboardStore, + type DashboardWriteMetadata, +} from "$lib/server/db/dashboard-store"; + +const IdentifierSchema = Type.String({ + minLength: 1, + pattern: "^[a-z0-9][a-z0-9-_.:]*$", +}); + +const PositionSchema = Type.Object( + { + index: Type.Optional(Type.Integer({ minimum: 0 })), + beforeId: Type.Optional(IdentifierSchema), + afterId: Type.Optional(IdentifierSchema), + }, + { additionalProperties: false, minProperties: 1 }, +); + +const AgentTargetSchema = Type.Union([ + Type.Object( + { + kind: Type.Literal("telemetry"), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("service"), + groupId: Type.Optional(IdentifierSchema), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("serviceGroup"), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("statusItem"), + stripId: Type.Optional(IdentifierSchema), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("module"), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), +]); + +const AddSectionOperationSchema = Type.Object( + { + type: Type.Literal("add_section"), + section: Type.Object( + { + id: IdentifierSchema, + title: Type.String({ minLength: 1 }), + layout: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("grid")])), + }, + { additionalProperties: false }, + ), + position: Type.Optional(PositionSchema), + }, + { additionalProperties: false }, +); + +const AddServiceOperationSchema = Type.Object( + { + type: Type.Literal("add_service"), + groupId: IdentifierSchema, + service: ServiceEntrySchema, + position: Type.Optional(PositionSchema), + }, + { additionalProperties: false }, +); + +const AddMetricCardOperationSchema = Type.Object( + { + type: Type.Literal("add_metric_card"), + card: TelemetryCardSchema, + position: Type.Optional(PositionSchema), + }, + { additionalProperties: false }, +); + +const ConnectDatasourceOperationSchema = Type.Object( + { + type: Type.Literal("connect_datasource"), + target: AgentTargetSchema, + datasource: DatasourceReferenceSchema, + }, + { additionalProperties: false }, +); + +const SetStatusRuleOperationSchema = Type.Object( + { + type: Type.Literal("set_status_rule"), + target: AgentTargetSchema, + thresholds: Type.Optional(ThresholdSchema), + severity: Type.Optional( + Type.Union([ + Type.Literal("neutral"), + Type.Literal("ok"), + Type.Literal("warning"), + Type.Literal("danger"), + Type.Literal("stale"), + Type.Literal("unavailable"), + ]), + ), + detail: Type.Optional(Type.String()), + value: Type.Optional(Type.String()), + }, + { additionalProperties: false, minProperties: 3 }, +); + +const ArrangeItemOperationSchema = Type.Object( + { + type: Type.Literal("arrange_item"), + area: Type.Union([ + Type.Literal("telemetry"), + Type.Literal("serviceGroups"), + Type.Literal("statusStrips"), + Type.Literal("modules"), + ]), + id: IdentifierSchema, + index: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); + +const RemoveItemOperationSchema = Type.Object( + { + type: Type.Literal("remove_item"), + target: AgentTargetSchema, + }, + { additionalProperties: false }, +); + +const NestedDashboardDocumentSchema = Type.Unsafe( + schemaWithoutRootId(DashboardDocumentSchema), +); + +const CreateDashboardOperationSchema = Type.Object( + { + type: Type.Literal("create_dashboard"), + document: NestedDashboardDocumentSchema, + }, + { additionalProperties: false }, +); + +export const AgentDashboardOperationSchema = Type.Union([ + CreateDashboardOperationSchema, + AddSectionOperationSchema, + AddServiceOperationSchema, + AddMetricCardOperationSchema, + ConnectDatasourceOperationSchema, + SetStatusRuleOperationSchema, + ArrangeItemOperationSchema, + RemoveItemOperationSchema, +]); + +export const AgentDashboardOperationsSchema = Type.Array(AgentDashboardOperationSchema, { + minItems: 1, +}); + +const PreviewRequestSchema = Type.Object( + { + action: Type.Literal("preview_changes"), + operations: AgentDashboardOperationsSchema, + }, + { additionalProperties: false }, +); + +const PublishRequestSchema = Type.Object( + { + action: Type.Literal("publish_changes"), + actor: Type.Optional(Type.String({ minLength: 1 })), + message: Type.Optional(Type.String({ minLength: 1 })), + operations: AgentDashboardOperationsSchema, + }, + { additionalProperties: false }, +); + +const RollbackRequestSchema = Type.Object( + { + action: Type.Literal("rollback_revision"), + actor: Type.Optional(Type.String({ minLength: 1 })), + message: Type.Optional(Type.String({ minLength: 1 })), + revisionId: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, +); + +export const AgentDashboardRequestSchema = Type.Union([ + PreviewRequestSchema, + PublishRequestSchema, + RollbackRequestSchema, +]); + +export type AgentDashboardOperation = Static; +export type AgentDashboardRequest = Static; + +export interface JsonPatchAddOperation { + op: "add"; + path: string; + value: unknown; +} + +export interface JsonPatchRemoveOperation { + op: "remove"; + path: string; +} + +export interface JsonPatchReplaceOperation { + op: "replace"; + path: string; + value: unknown; +} + +export type JsonPatchOperation = + | JsonPatchAddOperation + | JsonPatchRemoveOperation + | JsonPatchReplaceOperation; + +export interface AgentConfigError { + code: string; + message: string; + operationIndex?: number; + path: string; + details?: unknown; +} + +export type AgentPreviewResult = + | { + ok: true; + document: DashboardDocument; + patch: JsonPatchOperation[]; + } + | { + ok: false; + errors: AgentConfigError[]; + }; + +export type AgentPublishResult = + | { + ok: true; + document: DashboardDocument; + patch: JsonPatchOperation[]; + previousRevisionId: string | null; + revision: DashboardRevision; + } + | { + ok: false; + errors: AgentConfigError[]; + }; + +export interface AgentDashboardHandlerOptions { + store?: DashboardStore; + token?: string; +} + +export class AgentConfigAuthorizationError extends Error { + constructor() { + super("Unauthorized dashboard configuration request"); + this.name = "AgentConfigAuthorizationError"; + } +} + +const validateOperations = createAjv().compile( + AgentDashboardOperationsSchema, +); +const validateRequest = createAjv().compile( + AgentDashboardRequestSchema, +); + +function schemaWithoutRootId(schema: T): T { + const clone = JSON.parse(JSON.stringify(schema)) as T & { $id?: string }; + delete clone.$id; + return clone; +} + +function createAjv() { + return addFormats( + new Ajv({ + allErrors: true, + strict: false, + strictNumbers: true, + }), + ); +} + +export function previewDashboardChanges( + document: DashboardDocument, + operations: AgentDashboardOperation[], +): AgentPreviewResult { + const operationValidation = validateAgentOperations(operations); + if (operationValidation.length) return { ok: false, errors: operationValidation }; + + const before = structuredClone(document); + let next = structuredClone(document); + const errors: AgentConfigError[] = []; + + operations.forEach((operation, operationIndex) => { + if (errors.length) return; + const result = applyOperation(next, operation, operationIndex); + if (result.ok) { + next = result.document; + } else { + errors.push(...result.errors); + } + }); + + if (errors.length) return { ok: false, errors }; + + const validation = validateDashboardDocument(next); + if (!validation.valid) { + return { + ok: false, + errors: dashboardValidationErrors(validation), + }; + } + + return { + ok: true, + document: validation.data, + patch: createJsonPatch(before, validation.data), + }; +} + +export function publishDashboardChanges( + store: DashboardStore, + operations: AgentDashboardOperation[], + metadata: DashboardWriteMetadata = {}, +): AgentPublishResult { + const active = store.getActiveDashboard(); + if (!active) { + const create = operations[0]; + if (!create || create.type !== "create_dashboard") { + return { + ok: false, + errors: [ + { + code: "no_active_dashboard", + message: "No active dashboard exists; start with create_dashboard", + path: "/", + }, + ], + }; + } + } + + const base = active?.document || emptyDashboardDocument(); + const preview = previewDashboardChanges(base, operations); + if (!preview.ok) return preview; + + const revision = store.commitDashboard(preview.document, metadata); + return { + ok: true, + document: revision.document, + patch: preview.patch, + previousRevisionId: active?.currentRevisionId || null, + revision, + }; +} + +export function rollbackDashboardRevision( + store: DashboardStore, + revisionId: string, + metadata: DashboardWriteMetadata = {}, +) { + return store.rollbackToRevision(revisionId, metadata); +} + +export function authorizeAgentConfigRequest( + request: Request, + token = process.env.AGENT_CONFIG_TOKEN, +): { ok: true } { + if (!token) throw new AgentConfigAuthorizationError(); + + const authorization = request.headers.get("authorization") || ""; + const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]; + const headerToken = request.headers.get("x-agent-config-token"); + if (bearer === token || headerToken === token) return { ok: true }; + + throw new AgentConfigAuthorizationError(); +} + +export async function handleAgentDashboardRequest( + request: Request, + options: AgentDashboardHandlerOptions = {}, +): Promise { + const ownsStore = !options.store; + const store = options.store || createDashboardStore(); + + try { + authorizeAgentConfigRequest(request, options.token); + const body = await parseRequestBody(request); + const requestValidationErrors = validateAgentRequest(body); + if (requestValidationErrors.length) { + return Response.json( + { ok: false, errors: requestValidationErrors }, + { status: 400 }, + ); + } + + const agentRequest = body as AgentDashboardRequest; + if (agentRequest.action === "rollback_revision") { + const revision = rollbackDashboardRevision(store, agentRequest.revisionId, { + actor: agentRequest.actor || "agent", + message: agentRequest.message, + }); + return Response.json({ + ok: true, + action: agentRequest.action, + revision: serializeRevision(revision), + }); + } + + if (agentRequest.action === "preview_changes") { + const active = store.getActiveDashboard(); + const base = active?.document || previewBaseDocument(agentRequest.operations); + if (!base) { + return Response.json( + { + ok: false, + errors: [ + { + code: "no_active_dashboard", + message: "No active dashboard exists to preview against", + path: "/", + }, + ], + }, + { status: 409 }, + ); + } + const preview = previewDashboardChanges(base, agentRequest.operations); + return Response.json( + { + ...preview, + action: agentRequest.action, + }, + { status: preview.ok ? 200 : 422 }, + ); + } + + const publish = publishDashboardChanges(store, agentRequest.operations, { + actor: agentRequest.actor || "agent", + message: agentRequest.message, + }); + return Response.json( + { + ...publish, + action: agentRequest.action, + revision: publish.ok ? serializeRevision(publish.revision) : undefined, + }, + { status: publish.ok ? 200 : 422 }, + ); + } catch (error) { + if (error instanceof AgentConfigAuthorizationError) { + return Response.json( + { + ok: false, + errors: [ + { + code: "unauthorized", + message: error.message, + path: "/", + }, + ], + }, + { status: 401 }, + ); + } + + if (error instanceof SyntaxError) { + return Response.json( + { + ok: false, + errors: [ + { + code: "invalid_json", + message: error.message, + path: "/", + }, + ], + }, + { status: 400 }, + ); + } + + if (error instanceof DashboardRevisionNotFoundError) { + return Response.json( + { + ok: false, + errors: [ + { + code: "revision_not_found", + message: error.message, + path: "/revisionId", + }, + ], + }, + { status: 404 }, + ); + } + + throw error; + } finally { + if (ownsStore) store.close(); + } +} + +function validateAgentOperations(operations: unknown): AgentConfigError[] { + if (!validateOperations(operations)) { + return schemaErrors(validateOperations.errors || [], "invalid_operation_schema"); + } + + const createDashboardIndices = operations.flatMap((operation, index) => + operation.type === "create_dashboard" ? [index] : [], + ); + if (createDashboardIndices.length > 1) { + const operationIndex = createDashboardIndices[1]; + return [ + { + code: "invalid_operation_sequence", + message: "create_dashboard can only appear once", + operationIndex, + path: `/${operationIndex}`, + }, + ]; + } + if (createDashboardIndices[0] !== undefined && createDashboardIndices[0] !== 0) { + const operationIndex = createDashboardIndices[0]; + return [ + { + code: "invalid_operation_sequence", + message: "create_dashboard must be the first operation", + operationIndex, + path: `/${operationIndex}`, + }, + ]; + } + + return []; +} + +function validateAgentRequest(value: unknown): AgentConfigError[] { + if (validateRequest(value)) return []; + return schemaErrors(validateRequest.errors || [], "invalid_request_schema"); +} + +function schemaErrors(errors: ErrorObject[], code: string): AgentConfigError[] { + return errors.map((error) => ({ + code, + message: error.message || "is invalid", + operationIndex: operationIndexFromPath(error.instancePath), + path: error.instancePath || "/", + details: error.params, + })); +} + +function dashboardValidationErrors( + failure: DashboardValidationFailure, +): AgentConfigError[] { + return failure.errors.map((message, index) => ({ + code: "dashboard_validation_failed", + message, + path: failure.details[index]?.instancePath || "/", + details: failure.details[index]?.params, + })); +} + +function operationIndexFromPath(path: string): number | undefined { + const segments = path.split("/").filter(Boolean); + const operationsIndex = segments.indexOf("operations"); + const candidate = + operationsIndex >= 0 ? segments[operationsIndex + 1] : segments[0]; + const index = Number(candidate); + return Number.isInteger(index) ? index : undefined; +} + +type ApplyResult = + | { ok: true; document: DashboardDocument } + | { ok: false; errors: AgentConfigError[] }; + +function applyOperation( + document: DashboardDocument, + operation: AgentDashboardOperation, + operationIndex: number, +): ApplyResult { + const next = structuredClone(document); + + switch (operation.type) { + case "create_dashboard": + return { ok: true, document: structuredClone(operation.document) }; + case "add_section": + return addSection(next, operation, operationIndex); + case "add_service": + return addService(next, operation, operationIndex); + case "add_metric_card": + return addMetricCard(next, operation, operationIndex); + case "connect_datasource": + return connectDatasource(next, operation, operationIndex); + case "set_status_rule": + return setStatusRule(next, operation, operationIndex); + case "arrange_item": + return arrangeItem(next, operation, operationIndex); + case "remove_item": + return removeItem(next, operation, operationIndex); + } +} + +type AddSectionOperation = Extract; +type AddServiceOperation = Extract; +type AddMetricCardOperation = Extract; +type ConnectDatasourceOperation = Extract; +type SetStatusRuleOperation = Extract; +type ArrangeItemOperation = Extract; +type RemoveItemOperation = Extract; + +function addSection( + document: DashboardDocument, + operation: AddSectionOperation, + operationIndex: number, +): ApplyResult { + if (document.serviceGroups.some((group) => group.id === operation.section.id)) { + return failure( + "duplicate_id", + `Service group already exists: ${operation.section.id}`, + operationIndex, + "/serviceGroups", + ); + } + + const group: ServiceGroup = { + ...operation.section, + services: [], + }; + document.serviceGroups.push(group); + document.layout.serviceGroups = insertId( + document.layout.serviceGroups, + group.id, + operation.position, + ); + return { ok: true, document }; +} + +function addService( + document: DashboardDocument, + operation: AddServiceOperation, + operationIndex: number, +): ApplyResult { + const group = document.serviceGroups.find((item) => item.id === operation.groupId); + if (!group) { + return failure( + "group_not_found", + `Service group not found: ${operation.groupId}`, + operationIndex, + "/serviceGroups", + ); + } + + if ( + document.serviceGroups.some((item) => + item.services.some((service) => service.id === operation.service.id), + ) + ) { + return failure( + "duplicate_id", + `Service already exists: ${operation.service.id}`, + operationIndex, + "/serviceGroups", + ); + } + + group.services = insertItem(group.services, operation.service, operation.position); + return { ok: true, document }; +} + +function addMetricCard( + document: DashboardDocument, + operation: AddMetricCardOperation, + operationIndex: number, +): ApplyResult { + if (document.telemetry.some((card) => card.id === operation.card.id)) { + return failure( + "duplicate_id", + `Telemetry card already exists: ${operation.card.id}`, + operationIndex, + "/telemetry", + ); + } + + document.telemetry.push(operation.card); + document.layout.telemetry = insertId( + document.layout.telemetry, + operation.card.id, + operation.position, + ); + return { ok: true, document }; +} + +function connectDatasource( + document: DashboardDocument, + operation: ConnectDatasourceOperation, + operationIndex: number, +): ApplyResult { + if (!supportsDatasource(operation.target.kind)) { + return failure( + "unsupported_target", + `Target does not support datasources: ${operation.target.kind}`, + operationIndex, + targetPath(operation.target), + ); + } + + const targetResolution = resolveTarget(document, operation.target, operationIndex); + if (!targetResolution.ok) { + return { ok: false, errors: targetResolution.errors }; + } + + const target = targetResolution.target; + (target as DatasourceAgentTarget).datasource = operation.datasource; + return { ok: true, document }; +} + +function setStatusRule( + document: DashboardDocument, + operation: SetStatusRuleOperation, + operationIndex: number, +): ApplyResult { + if (operation.target.kind === "serviceGroup") { + return failure( + "unsupported_target", + `Target does not support status rules: ${operation.target.kind}`, + operationIndex, + targetPath(operation.target), + ); + } + if (operation.thresholds && operation.target.kind !== "telemetry") { + return failure( + "unsupported_target", + "Thresholds can only be set on telemetry targets", + operationIndex, + targetPath(operation.target), + ); + } + if (operation.detail !== undefined && !supportsDetail(operation.target.kind)) { + return failure( + "unsupported_target", + "Detail can only be set on telemetry, service, or module targets", + operationIndex, + targetPath(operation.target), + ); + } + if (operation.value !== undefined && !supportsStringValue(operation.target.kind)) { + return failure( + "unsupported_target", + "String values can only be set on status item or module targets", + operationIndex, + targetPath(operation.target), + ); + } + + const targetResolution = resolveTarget(document, operation.target, operationIndex); + if (!targetResolution.ok) { + return { ok: false, errors: targetResolution.errors }; + } + + const target = targetResolution.target; + if (operation.thresholds && operation.target.kind === "telemetry") { + (target as TelemetryCard).thresholds = operation.thresholds; + } + if (operation.severity) { + target.severity = operation.severity; + } + if (operation.detail !== undefined && supportsDetail(operation.target.kind)) { + (target as DetailAgentTarget).detail = operation.detail; + } + if (operation.value !== undefined && supportsStringValue(operation.target.kind)) { + (target as StringValueAgentTarget).value = operation.value; + } + + return { ok: true, document }; +} + +function arrangeItem( + document: DashboardDocument, + operation: ArrangeItemOperation, + operationIndex: number, +): ApplyResult { + const list = layoutList(document, operation.area); + if (!list.includes(operation.id)) { + return failure( + "item_not_found", + `Layout item not found: ${operation.id}`, + operationIndex, + `/layout/${operation.area}`, + ); + } + + const without = list.filter((id) => id !== operation.id); + const index = Math.min(operation.index, without.length); + without.splice(index, 0, operation.id); + setLayoutList(document, operation.area, without); + syncCollectionOrder(document, operation.area, without); + return { ok: true, document }; +} + +function removeItem( + document: DashboardDocument, + operation: RemoveItemOperation, + operationIndex: number, +): ApplyResult { + if (operation.target.kind === "telemetry") { + if (!document.telemetry.some((card) => card.id === operation.target.id)) { + return failure( + "item_not_found", + `Telemetry card not found: ${operation.target.id}`, + operationIndex, + "/telemetry", + ); + } + document.telemetry = document.telemetry.filter((card) => card.id !== operation.target.id); + document.layout.telemetry = document.layout.telemetry.filter((id) => id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "serviceGroup") { + if (!document.serviceGroups.some((group) => group.id === operation.target.id)) { + return failure( + "item_not_found", + `Service group not found: ${operation.target.id}`, + operationIndex, + "/serviceGroups", + ); + } + document.serviceGroups = document.serviceGroups.filter((group) => group.id !== operation.target.id); + document.layout.serviceGroups = document.layout.serviceGroups.filter((id) => id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "service") { + const matches = findServices(document, operation.target.id, operation.target.groupId); + if (!matches.length) { + return failure( + "item_not_found", + `Service not found: ${operation.target.id}`, + operationIndex, + "/serviceGroups", + ); + } + if (!operation.target.groupId && matches.length > 1) { + return failure( + "ambiguous_target", + `Service exists in multiple groups: ${operation.target.id}`, + operationIndex, + "/serviceGroups", + ); + } + + const match = matches[0]; + match.group.services = match.group.services.filter((service) => service.id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "module") { + if (!(document.modules || []).some((module) => module.id === operation.target.id)) { + return failure( + "item_not_found", + `Module not found: ${operation.target.id}`, + operationIndex, + "/modules", + ); + } + document.modules = (document.modules || []).filter((module) => module.id !== operation.target.id); + document.layout.modules = (document.layout.modules || []).filter((id) => id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "statusItem") { + const matches = findStatusItems(document, operation.target.id, operation.target.stripId); + if (!matches.length) { + return failure( + "item_not_found", + `Status item not found: ${operation.target.id}`, + operationIndex, + "/statusStrips", + ); + } + if (!operation.target.stripId && matches.length > 1) { + return failure( + "ambiguous_target", + `Status item exists in multiple strips: ${operation.target.id}`, + operationIndex, + "/statusStrips", + ); + } + + const [match] = matches; + match.strip.items = match.strip.items.filter((item) => item.id !== operation.target.id); + return { ok: true, document }; + } + + return failure("unsupported_target", "Cannot remove target kind", operationIndex, "/"); +} + +function resolveTarget( + document: DashboardDocument, + target: ConnectDatasourceOperation["target"], + operationIndex: number, +): ResolveTargetResult { + if (target.kind === "telemetry") { + const card = document.telemetry.find((item) => item.id === target.id); + if (!card) { + return resolutionFailure( + "item_not_found", + `Telemetry card not found: ${target.id}`, + operationIndex, + "/telemetry", + ); + } + return { ok: true, target: card }; + } + if (target.kind === "service") { + const matches = findServices(document, target.id, target.groupId); + if (!matches.length) { + return resolutionFailure( + "item_not_found", + `Service not found: ${target.id}`, + operationIndex, + "/serviceGroups", + ); + } + if (!target.groupId && matches.length > 1) { + return resolutionFailure( + "ambiguous_target", + `Service exists in multiple groups: ${target.id}`, + operationIndex, + "/serviceGroups", + ); + } + return { ok: true, target: matches[0].service }; + } + if (target.kind === "statusItem") { + const matches = findStatusItems(document, target.id, target.stripId); + if (!matches.length) { + return resolutionFailure( + "item_not_found", + `Status item not found: ${target.id}`, + operationIndex, + "/statusStrips", + ); + } + if (!target.stripId && matches.length > 1) { + return resolutionFailure( + "ambiguous_target", + `Status item exists in multiple strips: ${target.id}`, + operationIndex, + "/statusStrips", + ); + } + return { ok: true, target: matches[0].item }; + } + if (target.kind === "module") { + const module = (document.modules || []).find((item) => item.id === target.id); + if (!module) { + return resolutionFailure( + "item_not_found", + `Module not found: ${target.id}`, + operationIndex, + "/modules", + ); + } + return { ok: true, target: module }; + } + return resolutionFailure( + "item_not_found", + `Target not found: ${target.id}`, + operationIndex, + targetPath(target), + ); +} + +type ResolveTargetResult = + | { ok: true; target: ResolvedAgentTarget } + | { ok: false; errors: AgentConfigError[] }; +type ResolvedAgentTarget = TelemetryCard | ServiceEntry | StatusItem | DashboardModule; +type DatasourceAgentTarget = TelemetryCard | ServiceEntry | DashboardModule; +type DetailAgentTarget = TelemetryCard | ServiceEntry | DashboardModule; +type StringValueAgentTarget = StatusItem | DashboardModule; + +type DatasourceTargetKind = Extract< + ConnectDatasourceOperation["target"]["kind"], + "telemetry" | "service" | "module" +>; + +function supportsDatasource( + kind: ConnectDatasourceOperation["target"]["kind"], +): kind is DatasourceTargetKind { + return kind === "telemetry" || kind === "service" || kind === "module"; +} + +type DetailTargetKind = Extract< + SetStatusRuleOperation["target"]["kind"], + "telemetry" | "service" | "module" +>; + +function supportsDetail( + kind: SetStatusRuleOperation["target"]["kind"], +): kind is DetailTargetKind { + return kind === "telemetry" || kind === "service" || kind === "module"; +} + +type StringValueTargetKind = Extract< + SetStatusRuleOperation["target"]["kind"], + "statusItem" | "module" +>; + +function supportsStringValue( + kind: SetStatusRuleOperation["target"]["kind"], +): kind is StringValueTargetKind { + return kind === "statusItem" || kind === "module"; +} + +function findServices(document: DashboardDocument, id: string, groupId?: string) { + const groups = groupId + ? document.serviceGroups.filter((group) => group.id === groupId) + : document.serviceGroups; + return groups.flatMap((group) => + group.services.flatMap((service) => + service.id === id ? [{ group, service }] : [], + ), + ); +} + +function findStatusItems(document: DashboardDocument, id: string, stripId?: string) { + const strips = stripId + ? document.statusStrips.filter((strip) => strip.id === stripId) + : document.statusStrips; + return strips.flatMap((strip) => + strip.items.flatMap((item) => (item.id === id ? [{ strip, item }] : [])), + ); +} + +function resolutionFailure( + code: string, + message: string, + operationIndex: number, + path: string, +): ResolveTargetResult { + return { + ok: false, + errors: [ + { + code, + message, + operationIndex, + path, + }, + ], + }; +} + +function targetPath(target: ConnectDatasourceOperation["target"]): string { + if (target.kind === "telemetry") return "/telemetry"; + if (target.kind === "service") return "/serviceGroups"; + if (target.kind === "serviceGroup") return "/serviceGroups"; + if (target.kind === "statusItem") return "/statusStrips"; + return "/modules"; +} + +function layoutList(document: DashboardDocument, area: ArrangeItemOperation["area"]) { + if (area === "modules") return document.layout.modules || []; + return document.layout[area]; +} + +function setLayoutList( + document: DashboardDocument, + area: ArrangeItemOperation["area"], + value: string[], +) { + if (area === "modules") { + document.layout.modules = value; + return; + } + document.layout[area] = value; +} + +function syncCollectionOrder( + document: DashboardDocument, + area: ArrangeItemOperation["area"], + orderedIds: string[], +) { + if (area === "telemetry") { + document.telemetry = orderByIds(document.telemetry, orderedIds); + return; + } + if (area === "serviceGroups") { + document.serviceGroups = orderByIds(document.serviceGroups, orderedIds); + return; + } + if (area === "statusStrips") { + document.statusStrips = orderByIds(document.statusStrips, orderedIds); + return; + } + document.modules = orderByIds(document.modules || [], orderedIds); +} + +function orderByIds(items: T[], orderedIds: string[]): T[] { + const order = new Map(orderedIds.map((id, index) => [id, index])); + return [...items].sort( + (left, right) => + (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - + (order.get(right.id) ?? Number.MAX_SAFE_INTEGER), + ); +} + +function insertItem( + items: T[], + item: T, + position?: Static, +): T[] { + const next = [...items]; + next.splice(resolveInsertionIndex(next.map((entry) => entry.id), position), 0, item); + return next; +} + +function insertId( + ids: string[], + id: string, + position?: Static, +): string[] { + const next = [...ids]; + next.splice(resolveInsertionIndex(next, position), 0, id); + return next; +} + +function resolveInsertionIndex(ids: string[], position?: Static) { + if (!position) return ids.length; + if (typeof position.index === "number") return Math.min(position.index, ids.length); + if (position.beforeId) { + const beforeIndex = ids.indexOf(position.beforeId); + return beforeIndex >= 0 ? beforeIndex : ids.length; + } + if (position.afterId) { + const afterIndex = ids.indexOf(position.afterId); + return afterIndex >= 0 ? afterIndex + 1 : ids.length; + } + return ids.length; +} + +function failure( + code: string, + message: string, + operationIndex: number, + path: string, +): ApplyResult { + return { + ok: false, + errors: [ + { + code, + message, + operationIndex, + path, + }, + ], + }; +} + +function createJsonPatch( + before: unknown, + after: unknown, + path = "", +): JsonPatchOperation[] { + if (before === undefined) return [{ op: "add", path: path || "/", value: after }]; + if (after === undefined) return [{ op: "remove", path: path || "/" }]; + if (Object.is(before, after)) return []; + + if (!isObjectLike(before) || !isObjectLike(after)) { + return [{ op: "replace", path: path || "/", value: after }]; + } + + if (Array.isArray(before) || Array.isArray(after)) { + if (!Array.isArray(before) || !Array.isArray(after)) { + return [{ op: "replace", path: path || "/", value: after }]; + } + const operations: JsonPatchOperation[] = []; + const sharedLength = Math.min(before.length, after.length); + for (let index = 0; index < sharedLength; index += 1) { + operations.push( + ...createJsonPatch(before[index], after[index], `${path}/${index}`), + ); + } + for (let index = before.length - 1; index >= after.length; index -= 1) { + operations.push({ op: "remove", path: `${path}/${index}` }); + } + for (let index = before.length; index < after.length; index += 1) { + operations.push({ op: "add", path: `${path}/${index}`, value: after[index] }); + } + return operations; + } + + const beforeObject = before as Record; + const afterObject = after as Record; + const keys = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)]); + return [...keys].flatMap((key) => + createJsonPatch(beforeObject[key], afterObject[key], `${path}/${escapeJsonPointer(key)}`), + ); +} + +function isObjectLike(value: unknown): value is Record | unknown[] { + return typeof value === "object" && value !== null; +} + +function escapeJsonPointer(value: string): string { + return value.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +async function parseRequestBody(request: Request): Promise { + const text = await request.text(); + return text ? JSON.parse(text) : {}; +} + +function previewBaseDocument(operations: AgentDashboardOperation[]): DashboardDocument | null { + return operations[0]?.type === "create_dashboard" ? emptyDashboardDocument() : null; +} + +function serializeRevision(revision: DashboardRevision) { + return { + id: revision.id, + dashboardId: revision.dashboardId, + schemaVersion: revision.schemaVersion, + actor: revision.actor, + message: revision.message, + operation: revision.operation, + sourceRevisionId: revision.sourceRevisionId, + createdAt: revision.createdAt.toISOString(), + }; +} + +function emptyDashboardDocument(): DashboardDocument { + return { + schemaVersion: "dashboard.v1", + metadata: { + title: "Untitled Dashboard", + }, + layout: { + telemetry: [], + serviceGroups: [], + statusStrips: [], + modules: [], + }, + telemetry: [], + serviceGroups: [], + statusStrips: [], + modules: [], + }; +} diff --git a/apps/web/src/lib/server/dashboard.test.ts b/apps/web/src/lib/server/dashboard.test.ts new file mode 100644 index 0000000..5d93422 --- /dev/null +++ b/apps/web/src/lib/server/dashboard.test.ts @@ -0,0 +1,139 @@ +import { rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { createDashboardStore, type DashboardStore } from "./db/dashboard-store"; +import { loadDashboardRuntime } from "./dashboard"; + +const stores: DashboardStore[] = []; +const tempRoots: string[] = []; + +afterEach(() => { + stores.splice(0).forEach((store) => store.close()); + tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true })); +}); + +describe("dashboard runtime loader", () => { + test("seeds and loads the active dashboard from sqlite", async () => { + const store = await createTestStore(); + + const runtime = loadDashboardRuntime(store, { seedIfEmpty: true }); + + expect(runtime.state).toBe("ready"); + if (runtime.state !== "ready") throw new Error("expected ready dashboard"); + expect(runtime.document.metadata.title).toBe(dimensionLabDashboardFixture.metadata.title); + expect(runtime.document.metadata.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle); + expect(runtime.schemaVersion).toBe(dimensionLabDashboardFixture.schemaVersion); + expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId); + expect(store.listRevisions()).toHaveLength(1); + }); + + test("loads an existing active dashboard without reseeding", async () => { + const store = await createTestStore(); + const seed = store.commitDashboard(genericDashboardFixture, { + actor: "test", + message: "existing dashboard", + }); + + const runtime = loadDashboardRuntime(store); + + expect(runtime.state).toBe("ready"); + if (runtime.state !== "ready") throw new Error("expected ready dashboard"); + expect(runtime.currentRevisionId).toBe(seed.id); + expect(runtime.document.metadata.title).toBe(genericDashboardFixture.metadata.title); + expect(store.listRevisions()).toHaveLength(1); + }); + + test("refreshes an existing initial seed when the bundled seed changes", async () => { + const store = await createTestStore(); + const oldSeed = olderDimensionLabSeed(); + store.seedDashboardIfEmpty(oldSeed, { + actor: "initial-seed", + message: "load initial dashboard document", + }); + + const runtime = loadDashboardRuntime(store, { + refreshSeedDocument: true, + seedDocument: dimensionLabDashboardFixture, + seedIfEmpty: true, + }); + + expect(runtime.state).toBe("ready"); + if (runtime.state !== "ready") throw new Error("expected ready dashboard"); + expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain( + "auto-refresh", + ); + expect(store.listRevisions()).toHaveLength(2); + expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed"); + }); + + test("does not refresh a dashboard after a user-authored revision", async () => { + const store = await createTestStore(); + const oldSeed = olderDimensionLabSeed(); + store.seedDashboardIfEmpty(oldSeed, { + actor: "initial-seed", + message: "load initial dashboard document", + }); + store.commitDashboard( + { + ...oldSeed, + metadata: { + ...oldSeed.metadata, + title: "Custom Dashboard", + }, + }, + { + actor: "agent", + message: "customize dashboard", + }, + ); + + const runtime = loadDashboardRuntime(store, { + refreshSeedDocument: true, + seedDocument: dimensionLabDashboardFixture, + seedIfEmpty: true, + }); + + expect(runtime.state).toBe("ready"); + if (runtime.state !== "ready") throw new Error("expected ready dashboard"); + expect(runtime.document.metadata.title).toBe("Custom Dashboard"); + expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).not.toContain( + "auto-refresh", + ); + expect(store.listRevisions()).toHaveLength(2); + }); + + test("returns empty state when no dashboard is active and seeding is disabled", async () => { + const store = await createTestStore(); + + const runtime = loadDashboardRuntime(store); + + expect(runtime.state).toBe("empty"); + if (runtime.state !== "empty") throw new Error("expected empty dashboard"); + expect(runtime.title).toBe("No Dashboard Model"); + expect(store.listRevisions()).toHaveLength(0); + }); +}); + +async function createTestStore() { + const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-runtime-")); + tempRoots.push(root); + const store = createDashboardStore({ + databaseUrl: `file:${join(root, "dashboard.sqlite")}`, + }); + stores.push(store); + + return store; +} + +function olderDimensionLabSeed() { + const document = structuredClone(dimensionLabDashboardFixture); + document.statusStrips = document.statusStrips.map((strip) => ({ + ...strip, + items: strip.items.filter((item) => item.id !== "auto-refresh"), + })); + return document; +} diff --git a/apps/web/src/lib/server/dashboard.ts b/apps/web/src/lib/server/dashboard.ts new file mode 100644 index 0000000..33afa3a --- /dev/null +++ b/apps/web/src/lib/server/dashboard.ts @@ -0,0 +1,150 @@ +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { + createDashboardStore, + DashboardPersistenceValidationError, + type DashboardStore, +} from "$lib/server/db/dashboard-store"; +import { UnsupportedDashboardModelVersionError } from "$lib/server/db/model-migrations"; + +export type DashboardRuntimeState = + | DashboardRuntimeEmpty + | DashboardRuntimeInvalid + | DashboardRuntimeLoading + | DashboardRuntimeReady; + +export interface DashboardRuntimeReady { + state: "ready"; + document: DashboardDocument; + schemaVersion: string; + currentRevisionId: string; + liveDatasourceHydration?: { + enabled: boolean; + }; +} + +export interface DashboardRuntimeEmpty { + state: "empty"; + title: string; + subtitle: string; + message: string; +} + +export interface DashboardRuntimeLoading { + state: "loading"; + title: string; + subtitle: string; + message: string; +} + +export interface DashboardRuntimeInvalid { + state: "invalid"; + title: string; + subtitle: string; + message: string; + errors: string[]; +} + +export interface DashboardRuntimeOptions { + refreshSeedDocument?: boolean; + seedIfEmpty?: boolean; + seedDocument?: DashboardDocument; +} + +export function loadDashboardRuntime( + store?: DashboardStore, + options: DashboardRuntimeOptions = {}, +): DashboardRuntimeState { + const dashboardStore = store || createDashboardStore(); + + try { + const seedDocument = options.seedDocument || dimensionLabDashboardFixture; + const active = dashboardStore.getActiveDashboard(); + if (active) { + if ( + options.refreshSeedDocument && + shouldRefreshSeedDashboard(active, seedDocument) + ) { + const refreshed = dashboardStore.commitDashboard(seedDocument, { + actor: "initial-seed", + message: "refresh bundled dashboard document", + }); + return readyRuntimeState(refreshed.document, refreshed.id); + } + + return readyRuntimeState(active.document, active.currentRevisionId); + } + + if (!options.seedIfEmpty) { + return { + state: "empty", + title: "No Dashboard Model", + subtitle: "No active document", + message: "No validated dashboard document is active yet.", + }; + } + + const seeded = dashboardStore.seedDashboardIfEmpty( + seedDocument, + { + actor: "initial-seed", + message: "load initial dashboard document", + }, + ); + + return readyRuntimeState(seeded.document, seeded.id); + } catch (error) { + if (error instanceof DashboardPersistenceValidationError) { + return invalidRuntimeState(error.failure.errors); + } + + if (error instanceof UnsupportedDashboardModelVersionError) { + return invalidRuntimeState([error.message]); + } + + throw error; + } finally { + if (!store) dashboardStore.close(); + } +} + +function readyRuntimeState( + document: DashboardDocument, + currentRevisionId: string, +): DashboardRuntimeReady { + return { + state: "ready", + document, + schemaVersion: document.schemaVersion, + currentRevisionId, + }; +} + +function invalidRuntimeState(errors: string[]): DashboardRuntimeInvalid { + return { + state: "invalid", + title: "Invalid Dashboard Model", + subtitle: "Validation failed", + message: "The active dashboard document could not be validated.", + errors, + }; +} + +function shouldRefreshSeedDashboard( + active: { document: DashboardDocument; revision: { actor: string } }, + seedDocument: DashboardDocument, +): boolean { + if (active.revision.actor !== "initial-seed") return false; + if (!isBundledDimensionLabSeed(active.document, seedDocument)) return false; + return JSON.stringify(active.document) !== JSON.stringify(seedDocument); +} + +function isBundledDimensionLabSeed( + document: DashboardDocument, + seedDocument: DashboardDocument, +): boolean { + return ( + document.metadata.title === seedDocument.metadata.title && + document.metadata.description === seedDocument.metadata.description + ); +} diff --git a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts new file mode 100644 index 0000000..d24b48b --- /dev/null +++ b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test, vi } from "vitest"; +import { + DASHBOARD_SCHEMA_VERSION, + type DashboardDocument, +} 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 () => { + 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, "40"], + [1771430120, "30"], + ], + }, + { + metric: { host: "linux-gpu" }, + values: [ + [1771430000, "12"], + [1771430060, "24"], + [1771430120, "36"], + ], + }, + ], + }, + }); + } + + if (url.startsWith("https://prometheus.example/api/v1/query")) { + const query = new URL(url).searchParams.get("query") || ""; + if (query.includes("node_boot_time_seconds")) { + return prometheusVector("90061"); + } + if (query.includes("node_load15")) return prometheusVector("0.40"); + if (query.includes("node_load5")) return prometheusVector("0.46"); + if (query.includes("node_load1")) return prometheusVector("0.34"); + + return jsonResponse({ + status: "success", + data: { + result: [ + { + metric: { host: "linux-infra", mountpoint: "/home" }, + value: [1771430400, "88"], + }, + ], + }, + }); + } + + if (url.startsWith("https://api.open-meteo.com/v1/forecast")) { + return jsonResponse({ + current: { + apparent_temperature: 20.9, + temperature_2m: 21.4, + weather_code: 0, + wind_speed_10m: 12, + }, + }); + } + + if (url === "https://service.example/health") { + return jsonResponse({ + status: "UP", + ping: 42, + }); + } + + throw new Error(`Unhandled test request: ${url}`); + }); + + const resolved = await resolveDashboardDatasources(testDashboard(), { + fetch, + prometheusBaseUrl: "https://prometheus.example", + }); + + 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" }), + ); + + const telemetry = resolved.telemetry[0]; + expect(telemetry.value).toEqual({ kind: "percent", value: 88 }); + expect(telemetry.severity).toBe("warning"); + expect(telemetry.detail).toBe("linux-infra /home"); + expect(telemetry.sparkline).toEqual([12, 40, 36]); + + const service = resolved.serviceGroups[0].services[0]; + expect(service.severity).toBe("ok"); + expect(service.detail).toBe("42 ms"); + + const weather = resolved.modules?.find((module) => module.id === "weather-amsterdam"); + expect(weather?.value).toBe("21.4 C"); + expect(weather?.detail).toBe("Clear - feels 20.9 C - wind 12 km/h"); + expect(weather?.severity).toBe("ok"); + + const summary = resolved.modules?.find((module) => module.id === "runtime-health-summary"); + expect(summary?.value).toBe("all systems operational"); + expect(summary?.detail).toBe("1 service ok"); + expect(summary?.severity).toBe("ok"); + + expect(resolved.statusStrips[0].items).toEqual([ + { id: "system-status", label: "System Status", value: "All systems operational", severity: "ok" }, + { id: "last-sync", label: "Last Sync", value: "just now", severity: "ok" }, + { id: "uptime", label: "Uptime", value: "1d 1h 1m", severity: "ok" }, + { id: "load-avg", label: "Load Avg", value: "0.34 0.46 0.40", severity: "neutral" }, + { id: "auto-refresh", label: "Auto Refresh", value: "15s", severity: "neutral" }, + ]); + + 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(); + +function testDashboard(): DashboardDocument { + return { + schemaVersion: DASHBOARD_SCHEMA_VERSION, + metadata: { + title: "System Overview", + refreshIntervalSeconds: 15, + }, + layout: { + telemetry: ["cpu"], + serviceGroups: ["services"], + statusStrips: ["footer"], + modules: ["weather-amsterdam", "runtime-health-summary"], + }, + telemetry: [ + { + id: "cpu", + label: "CPU", + value: { kind: "percent", value: 1 }, + detail: "fallback", + severity: "stale", + thresholds: { warning: 70, danger: 90 }, + datasource: { + type: "external", + adapter: "prometheus", + reference: "fixture_cpu_query", + }, + sparkline: [1], + }, + ], + serviceGroups: [ + { + id: "services", + title: "Services", + services: [ + { + id: "api", + label: "API", + description: "Example API", + severity: "stale", + detail: "fallback", + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://service.example/health", + }, + }, + ], + }, + ], + statusStrips: [ + { + id: "footer", + items: [ + { id: "system-status", label: "System Status", value: "fallback", severity: "ok" }, + { id: "last-sync", label: "Last Sync", value: "fallback", severity: "stale" }, + { id: "uptime", label: "Uptime", value: "fallback", severity: "ok" }, + { id: "load-avg", label: "Load Avg", value: "fallback", severity: "neutral" }, + { id: "auto-refresh", label: "Auto Refresh", value: "fallback", severity: "neutral" }, + ], + }, + ], + modules: [ + { + id: "weather-amsterdam", + kind: "weather", + title: "Amsterdam", + value: "fallback", + detail: "fallback", + severity: "stale", + datasource: { + type: "external", + adapter: "weather", + reference: "open-meteo:latitude=52.3676&longitude=4.9041", + }, + }, + { + id: "runtime-health-summary", + kind: "summary", + title: "Runtime Health", + value: "fallback", + detail: "fallback", + severity: "stale", + datasource: { + type: "placeholder", + reason: "summary pending live health aggregation", + }, + }, + ], + }; +} + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + headers: { "content-type": "application/json" }, + }); +} + +function prometheusVector(value: string): Response { + return jsonResponse({ + status: "success", + data: { + result: [ + { + metric: { host: "linux-infra" }, + value: [1771430400, value], + }, + ], + }, + }); +} diff --git a/apps/web/src/lib/server/datasources/index.ts b/apps/web/src/lib/server/datasources/index.ts new file mode 100644 index 0000000..62df13a --- /dev/null +++ b/apps/web/src/lib/server/datasources/index.ts @@ -0,0 +1,919 @@ +import type { + DashboardDocument, + DashboardModule, + MetricValue, + ServiceEntry, + ServiceGroup, + Severity, + StatusItem, + StatusStrip, + TelemetryCard, +} from "@dimensionlab/dashboard-model"; + +export type DashboardTileReference = + | { kind: "telemetry"; id: string } + | { kind: "service"; groupId: string; id: string } + | { kind: "module"; id: string } + | { kind: "status"; stripId: string; id: string }; + +export type DashboardTileItem = + | DashboardModule + | ServiceEntry + | StatusItem + | TelemetryCard; + +export type DashboardTileResolution = + | { + state: "ready"; + tile: DashboardTileReference; + item: DashboardTileItem; + } + | { + state: "not_found"; + tile: DashboardTileReference; + message: string; + } + | { + state: "disabled"; + tile: DashboardTileReference; + message: string; + }; + +export interface DatasourceResolutionOptions { + fetch?: DatasourceFetch; + now?: () => number; + prometheusBaseUrl?: string; + prometheusRangeSeconds?: number; + prometheusStepSeconds?: number; + requestTimeoutMs?: number; +} + +export async function resolveDashboardDatasources( + document: DashboardDocument, + options: DatasourceResolutionOptions = {}, +): Promise { + const context = datasourceContext(options); + const telemetry = await Promise.all( + document.telemetry.map((card) => resolveTelemetryCard(card, context)), + ); + const serviceGroups = await Promise.all( + document.serviceGroups.map((group) => resolveServiceGroup(group, context)), + ); + const modules = await resolveModules(document.modules || [], serviceGroups, context); + const statusStrips = await resolveStatusStrips( + document.statusStrips, + document.metadata.refreshIntervalSeconds, + serviceGroups, + context, + ); + + return { + ...structuredClone(document), + telemetry, + serviceGroups, + modules, + statusStrips, + }; +} + +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]; +} + +interface PrometheusMatrixResult { + metric?: Record; + values?: Array<[number, string]>; +} + +function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext { + const fetch = options.fetch || globalThis.fetch; + + return { + fetch, + fetchIdentity: datasourceFetchIdentity(fetch), + now: options.now || Date.now, + prometheusBaseUrl: + options.prometheusBaseUrl || + process.env.PROMETHEUS_BASE_URL || + "https://prometheus.dimensionlab.net", + prometheusRangeSeconds: options.prometheusRangeSeconds || 60 * 60, + prometheusStepSeconds: options.prometheusStepSeconds || 120, + requestTimeoutMs: options.requestTimeoutMs || 2_500, + }; +} + +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, +): Promise { + if (card.datasource?.type !== "external" || card.datasource.adapter !== "prometheus") { + return structuredClone(card); + } + + try { + const [instant, range] = await Promise.all([ + prometheusQuery(card.datasource.reference, context), + prometheusRangeQuery(card.datasource.reference, context), + ]); + const current = pickMaxVectorResult(instant); + const currentValue = Number(current?.value?.[1]); + const sparkline = matrixPoints(range); + + if (!Number.isFinite(currentValue)) return markStale(card, "no telemetry data"); + + return { + ...structuredClone(card), + value: metricValueWithLiveNumber(card.value, currentValue), + severity: severityForValue(currentValue, card.thresholds), + detail: prometheusMetricDetail(current?.metric || {}, card.detail), + sparkline: sparkline.length ? sparkline : card.sparkline, + }; + } catch { + return markStale(card, card.detail || "telemetry unavailable"); + } +} + +async function resolveServiceGroup( + group: ServiceGroup, + context: DatasourceContext, +): Promise { + return { + ...structuredClone(group), + services: await Promise.all( + group.services.map((service) => resolveService(service, context)), + ), + }; +} + +async function resolveService( + service: ServiceEntry, + context: DatasourceContext, +): Promise { + const datasource = service.datasource; + if (datasource?.type !== "external") return structuredClone(service); + + if (datasource.adapter === "http-status") { + return resolveHttpStatusService(service, datasource.reference, context); + } + + if (datasource.adapter === "prometheus") { + return resolvePrometheusService(service, datasource.reference, context); + } + + return structuredClone(service); +} + +async function resolveHttpStatusService( + service: ServiceEntry, + reference: string, + context: DatasourceContext, +): Promise { + const request = parseHttpStatusReference(reference); + if (!request) return structuredClone(service); + + try { + const startedAt = Date.now(); + const response = await fetchWithTimeout( + context.fetch, + request.url, + { + cache: "no-store", + method: request.method, + }, + context.requestTimeoutMs, + ); + const elapsedMs = Math.max(0, Math.round(Date.now() - startedAt)); + const badge = await uptimeBadge(response); + if (badge) { + const ping = badge.ping; + const status = badge.status || "UNKNOWN"; + return { + ...structuredClone(service), + severity: uptimeBadgeSeverity(status), + detail: Number.isFinite(ping) + ? `${Math.round(ping as number)} ms` + : status.toLowerCase(), + }; + } + + return { + ...structuredClone(service), + severity: response.ok ? "ok" : response.status >= 500 ? "danger" : "warning", + detail: response.ok ? `${elapsedMs} ms` : `HTTP ${response.status}`, + }; + } catch { + return { + ...structuredClone(service), + severity: "unavailable", + detail: "unavailable", + }; + } +} + +async function resolvePrometheusService( + service: ServiceEntry, + reference: string, + context: DatasourceContext, +): Promise { + try { + const result = pickMaxVectorResult(await prometheusQuery(reference, context)); + const value = Number(result?.value?.[1]); + const ok = Number.isFinite(value) && value > 0; + + return { + ...structuredClone(service), + severity: ok ? "ok" : "unavailable", + detail: ok ? "up" : "down", + }; + } catch { + return { + ...structuredClone(service), + severity: "unavailable", + detail: "unavailable", + }; + } +} + +async function resolveModules( + modules: DashboardModule[], + serviceGroups: ServiceGroup[], + context: DatasourceContext, +): Promise { + const resolved = await Promise.all( + modules.map((module) => resolveModule(module, context)), + ); + return resolved.map((module) => + module.id === "runtime-health-summary" + ? runtimeHealthSummary(module, serviceGroups) + : module, + ); +} + +async function resolveModule( + module: DashboardModule, + context: DatasourceContext, +): Promise { + if (module.datasource?.type !== "external" || module.datasource.adapter !== "weather") { + return structuredClone(module); + } + + try { + const weather = await requestJson( + context.fetch, + openMeteoUrl(module.datasource.reference), + context.requestTimeoutMs, + ); + const current = weather.current; + const temperature = Number(current?.temperature_2m); + if (!Number.isFinite(temperature)) { + return { + ...structuredClone(module), + severity: "stale", + detail: "weather unavailable", + }; + } + + const apparent = Number(current?.apparent_temperature); + const wind = Number(current?.wind_speed_10m); + const condition = weatherCondition(Number(current?.weather_code)); + + return { + ...structuredClone(module), + value: `${temperature.toFixed(1)} C`, + detail: [ + condition, + Number.isFinite(apparent) ? `feels ${apparent.toFixed(1)} C` : "", + Number.isFinite(wind) ? `wind ${Math.round(wind)} km/h` : "", + ].filter(Boolean).join(" - "), + severity: "ok", + }; + } catch { + return { + ...structuredClone(module), + severity: "stale", + detail: "weather unavailable", + }; + } +} + +function runtimeHealthSummary( + module: DashboardModule, + serviceGroups: ServiceGroup[], +): DashboardModule { + const services = serviceGroups.flatMap((group) => group.services); + const down = services.filter((service) => + service.severity === "danger" || service.severity === "unavailable" + ).length; + const warning = services.filter((service) => service.severity === "warning").length; + const ok = services.filter((service) => service.severity === "ok").length; + + if (down > 0) { + return { + ...structuredClone(module), + value: `${down} service${down === 1 ? "" : "s"} down`, + detail: `${warning} warning${warning === 1 ? "" : "s"} - ${ok} service${ok === 1 ? "" : "s"} ok`, + severity: "danger", + }; + } + + if (warning > 0) { + return { + ...structuredClone(module), + value: `${warning} service${warning === 1 ? "" : "s"} warning`, + detail: `${ok} service${ok === 1 ? "" : "s"} ok`, + severity: "warning", + }; + } + + return { + ...structuredClone(module), + value: "all systems operational", + detail: `${ok} service${ok === 1 ? "" : "s"} ok`, + severity: "ok", + }; +} + +async function resolveStatusStrips( + strips: StatusStrip[], + refreshIntervalSeconds: number | undefined, + serviceGroups: ServiceGroup[], + context: DatasourceContext, +): Promise { + const [uptime, loadAverage] = await Promise.all([ + prometheusScalar( + 'time() - node_boot_time_seconds{job="node",host="linux-infra"}', + context, + ).catch(() => null), + prometheusLoadAverage(context).catch(() => null), + ]); + const health = serviceHealthSummary(serviceGroups); + + return strips.map((strip) => ({ + ...structuredClone(strip), + items: strip.items.map((item) => + resolveStatusItem(item, { + health, + loadAverage, + refreshIntervalSeconds, + uptime, + }), + ), + })); +} + +function resolveStatusItem( + item: StatusItem, + values: { + health: { severity: Severity; value: string }; + loadAverage: string | null; + refreshIntervalSeconds?: number; + uptime: number | null; + }, +): StatusItem { + if (item.id === "system-status") { + return { + ...structuredClone(item), + value: values.health.value, + severity: values.health.severity, + }; + } + + if (item.id === "last-sync") { + return { + ...structuredClone(item), + value: "just now", + severity: "ok", + }; + } + + if (item.id === "uptime" && values.uptime !== null) { + return { + ...structuredClone(item), + value: formatDuration(values.uptime), + severity: "ok", + }; + } + + if (item.id === "load-avg" && values.loadAverage) { + return { + ...structuredClone(item), + value: values.loadAverage, + severity: "neutral", + }; + } + + if (item.id === "auto-refresh" && values.refreshIntervalSeconds) { + return { + ...structuredClone(item), + value: `${values.refreshIntervalSeconds}s`, + severity: "neutral", + }; + } + + 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; +} { + const services = serviceGroups.flatMap((group) => group.services); + const down = services.filter((service) => + service.severity === "danger" || service.severity === "unavailable" + ).length; + const warning = services.filter((service) => service.severity === "warning").length; + + if (down > 0) { + return { + severity: "danger", + value: `${down} service${down === 1 ? "" : "s"} down`, + }; + } + + if (warning > 0) { + return { + severity: "warning", + value: `${warning} service${warning === 1 ? "" : "s"} warning`, + }; + } + + return { + severity: "ok", + value: "All systems operational", + }; +} + +async function prometheusQuery( + query: string, + context: DatasourceContext, +): Promise { + const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query", { + query, + }); + const payload = await requestJson>( + context.fetch, + url, + context.requestTimeoutMs, + ); + return payload.status === "success" ? payload.data.result || [] : []; +} + +async function prometheusRangeQuery( + query: string, + context: DatasourceContext, +): Promise { + const end = Math.floor(Date.now() / 1000); + const start = end - context.prometheusRangeSeconds; + const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query_range", { + query, + start: String(start), + end: String(end), + step: String(context.prometheusStepSeconds), + }); + const payload = await requestJson>( + context.fetch, + url, + context.requestTimeoutMs, + ); + return payload.status === "success" ? payload.data.result || [] : []; +} + +async function prometheusScalar( + query: string, + context: DatasourceContext, +): Promise { + const result = pickMaxVectorResult(await prometheusQuery(query, context)); + const value = Number(result?.value?.[1]); + return Number.isFinite(value) ? value : null; +} + +async function prometheusLoadAverage(context: DatasourceContext): Promise { + const [one, five, fifteen] = await Promise.all([ + prometheusScalar('node_load1{job="node",host="linux-infra"}', context), + prometheusScalar('node_load5{job="node",host="linux-infra"}', context), + prometheusScalar('node_load15{job="node",host="linux-infra"}', context), + ]); + + if (one === null || five === null || fifteen === null) return null; + return [one, five, fifteen].map((value) => value.toFixed(2)).join(" "); +} + +async function requestJson( + fetch: DatasourceFetch, + url: string, + timeoutMs: number, +): Promise { + const response = await fetchWithTimeout( + fetch, + url, + { cache: "no-store" }, + timeoutMs, + ); + if (!response.ok) throw new Error(`request failed: ${response.status}`); + return response.json() as Promise; +} + +async function fetchWithTimeout( + fetch: DatasourceFetch, + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +function prometheusApiUrl( + baseUrl: string, + pathname: string, + params: Record, +): string { + const url = new URL(pathname, ensureTrailingSlash(baseUrl)); + Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); + return url.toString(); +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + +function pickMaxVectorResult( + results: PrometheusVectorResult[], +): PrometheusVectorResult | null { + return results.reduce((winner, item) => { + if (!winner) return item; + return Number(item.value?.[1]) > Number(winner.value?.[1]) ? item : winner; + }, null); +} + +function matrixPoints(results: PrometheusMatrixResult[]): number[] { + const byTimestamp = new Map(); + + for (const series of results) { + for (const [timestamp, value] of series.values || []) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) continue; + const previous = byTimestamp.get(timestamp); + if (previous === undefined || numeric > previous) { + byTimestamp.set(timestamp, numeric); + } + } + } + + return [...byTimestamp.entries()] + .sort(([left], [right]) => left - right) + .map(([, value]) => value); +} + +function metricValueWithLiveNumber(value: MetricValue, nextValue: number): MetricValue { + if (value.kind === "text") return value; + return { + ...value, + value: value.kind === "percent" ? clamp(nextValue, 0, 100) : Math.max(0, nextValue), + }; +} + +function severityForValue( + value: number, + thresholds: TelemetryCard["thresholds"], +): Severity { + if (thresholds?.danger !== undefined && value >= thresholds.danger) return "danger"; + if (thresholds?.warning !== undefined && value >= thresholds.warning) return "warning"; + return "ok"; +} + +function prometheusMetricDetail( + metric: Record, + fallback = "", +): string { + const host = metric.host || metric.instance || metric.job; + const detail = metric.mountpoint || metric.name || metric.container || metric.id; + const parts = [host, detail].filter(Boolean); + if (parts.length) return parts.join(" "); + return fallback.replace(/^fallback\s*-\s*/i, "") || "telemetry"; +} + +function markStale(card: TelemetryCard, detail: string): TelemetryCard { + return { + ...structuredClone(card), + severity: "stale", + detail, + }; +} + +function parseHttpStatusReference(reference: string): { method: string; url: string } | null { + const match = /^(GET|HEAD|POST)\s+(.+)$/i.exec(reference.trim()); + if (!match) return null; + return { method: match[1].toUpperCase(), url: match[2] }; +} + +interface PrometheusResponse { + status: string; + data: { + result?: T[]; + }; +} + +interface OpenMeteoResponse { + current?: { + apparent_temperature?: number; + temperature_2m?: number; + weather_code?: number; + wind_speed_10m?: number; + }; +} + +interface UptimeBadgeResponse { + status?: string; + ping?: number; +} + +async function uptimeBadge(response: Response): Promise { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("json")) return null; + + try { + const payload = await response.clone().json() as UptimeBadgeResponse; + return typeof payload.status === "string" ? payload : null; + } catch { + return null; + } +} + +function uptimeBadgeSeverity(status = ""): Severity { + const normalized = status.toUpperCase(); + if (normalized === "UP") return "ok"; + if (normalized === "PENDING" || normalized === "MAINTENANCE") return "warning"; + return "unavailable"; +} + +function openMeteoUrl(reference: string): string { + const params = new URLSearchParams(); + const serialized = reference.startsWith("open-meteo:") + ? reference.slice("open-meteo:".length) + : reference; + + new URLSearchParams(serialized).forEach((value, key) => { + params.set(key, value); + }); + params.set( + "current", + "temperature_2m,apparent_temperature,weather_code,wind_speed_10m", + ); + params.set("timezone", "Europe/Amsterdam"); + + return `https://api.open-meteo.com/v1/forecast?${params.toString()}`; +} + +function weatherCondition(code: number): string { + if (code === 0) return "Clear"; + if ([1, 2].includes(code)) return "Partly cloudy"; + if (code === 3) return "Cloudy"; + if ([45, 48].includes(code)) return "Fog"; + if (code >= 51 && code <= 67) return "Rain"; + if (code >= 71 && code <= 77) return "Snow"; + if (code >= 80 && code <= 82) return "Showers"; + if (code >= 95) return "Thunderstorm"; + return "Mixed"; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function formatDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + 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/apps/web/src/lib/testing/external-api-mocks.test.ts b/apps/web/src/lib/testing/external-api-mocks.test.ts new file mode 100644 index 0000000..7d487ae --- /dev/null +++ b/apps/web/src/lib/testing/external-api-mocks.test.ts @@ -0,0 +1,55 @@ +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; +import { externalApiHandlers } from "./external-api-mocks"; + +const server = setupServer(...externalApiHandlers); + +describe("external API mocks", () => { + beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + test("defines deterministic handlers for deferred datasource adapters", () => { + expect(externalApiHandlers).toHaveLength(3); + expect(externalApiHandlers.map((handler) => handler.info.header)).toEqual([ + "GET https://prometheus.dimensionlab.net/api/v1/query", + "GET https://uptime.dimensionlab.net/api/status-page/*", + "GET https://api.open-meteo.com/v1/forecast", + ]); + }); + + test("intercepts deferred datasource requests without live services", async () => { + const prometheus = await fetch( + "https://prometheus.dimensionlab.net/api/v1/query", + ).then((response) => response.json()); + const status = await fetch( + "https://uptime.dimensionlab.net/api/status-page/dimensionlab", + ).then((response) => response.json()); + const weather = await fetch( + "https://api.open-meteo.com/v1/forecast?latitude=52.37&longitude=4.9", + ).then((response) => response.json()); + + expect(prometheus).toMatchObject({ + status: "success", + data: { result: [{ value: [1771430400, "1"] }] }, + }); + expect(status).toMatchObject({ + status: "ok", + incidents: [], + }); + expect(weather).toMatchObject({ + current: { + temperature_2m: 21.4, + weather_code: 0, + }, + }); + }); +}); diff --git a/apps/web/src/lib/testing/external-api-mocks.ts b/apps/web/src/lib/testing/external-api-mocks.ts new file mode 100644 index 0000000..c8f6399 --- /dev/null +++ b/apps/web/src/lib/testing/external-api-mocks.ts @@ -0,0 +1,33 @@ +import { http, HttpResponse } from "msw"; + +export const externalApiHandlers = [ + http.get("https://prometheus.dimensionlab.net/api/v1/query", () => + HttpResponse.json({ + status: "success", + data: { + resultType: "vector", + result: [ + { + metric: { instance: "fixture" }, + value: [1771430400, "1"], + }, + ], + }, + }), + ), + http.get("https://uptime.dimensionlab.net/api/status-page/*", () => + HttpResponse.json({ + status: "ok", + incidents: [], + monitors: [{ name: "fixture", status: "up" }], + }), + ), + http.get("https://api.open-meteo.com/v1/forecast", () => + HttpResponse.json({ + current: { + temperature_2m: 21.4, + weather_code: 0, + }, + }), + ), +]; diff --git a/apps/web/src/lib/ui-adapter/model-renderer.test.ts b/apps/web/src/lib/ui-adapter/model-renderer.test.ts new file mode 100644 index 0000000..2f8178d --- /dev/null +++ b/apps/web/src/lib/ui-adapter/model-renderer.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { type DashboardDocument } from "@dimensionlab/dashboard-model"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { 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); + + expect(dashboard.title).toBe(dimensionLabDashboardFixture.metadata.title); + expect(dashboard.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle); + expect(dashboard.telemetry.map((card) => card.id)).toEqual( + dimensionLabDashboardFixture.layout.telemetry, + ); + expect(dashboard.telemetry[0]).toMatchObject({ + label: "Infra RAM", + icon: "mdi:memory", + severity: "ok", + }); + expect(dashboard.serviceGroups.map((group) => group.id)).toEqual( + dimensionLabDashboardFixture.layout.serviceGroups, + ); + expect(dashboard.serviceGroups.find((group) => group.id === "runtime-health")?.layout).toBe( + "grid", + ); + expect(dashboard.modules.map((module) => module.id)).toEqual([ + "weather-amsterdam", + "runtime-health-summary", + ]); + expect(dashboard.statusItems.map((item) => item.id)).toEqual([ + "footer-status:system-status", + "footer-status:last-sync", + "footer-status:uptime", + "footer-status:load-avg", + "footer-status:auto-refresh", + ]); + expect(dashboard.statusStripId).toBe("footer-status"); + }); + + test("projects a second fixture through the same mapper", () => { + const dashboard = dashboardDocumentToUiDashboard(genericDashboardFixture); + + expect(dashboard.title).toBe("Operations Console"); + expect(dashboard.telemetry.map((card) => card.id)).toEqual([ + "service-uptime", + "queue-depth", + ]); + expect(dashboard.serviceGroups[0]?.services.map((service) => service.id)).toEqual([ + "identity", + "scheduler", + ]); + expect(dashboard.modules[0]).toMatchObject({ + id: "ambient", + title: "Environment", + icon: "mdi:radar", + }); + }); + + test("returns intentional empty arrays for missing optional sections", () => { + const emptyModel: DashboardDocument = { + ...genericDashboardFixture, + layout: { + density: "compact", + telemetry: [], + serviceGroups: [], + statusStrips: [], + modules: [], + }, + telemetry: [], + serviceGroups: [], + statusStrips: [], + modules: [], + }; + + const dashboard = dashboardDocumentToUiDashboard(emptyModel); + + expect(dashboard.telemetry).toEqual([]); + expect(dashboard.serviceGroups).toEqual([]); + expect(dashboard.statusItems).toEqual([]); + expect(dashboard.modules).toEqual([]); + }); + + test("does not hardcode environment-specific content in mapper source", () => { + 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"); + expect(source).not.toContain("forgejo"); + }); +}); diff --git a/apps/web/src/lib/ui-adapter/model-renderer.ts b/apps/web/src/lib/ui-adapter/model-renderer.ts new file mode 100644 index 0000000..7ac1912 --- /dev/null +++ b/apps/web/src/lib/ui-adapter/model-renderer.ts @@ -0,0 +1,111 @@ +import type { + DashboardDocument, + DashboardModule, + ServiceEntry, + ServiceGroup, + StatusItem, + StatusStrip, + TelemetryCard, +} from "@dimensionlab/dashboard-model"; +import type { + UiDashboardPreview, + UiModuleBlock, + UiServiceGroup, + UiServiceRow, + UiStatusItem, + UiTelemetryCard, +} from "@dimensionlab/ui"; + +export function dashboardDocumentToUiDashboard( + document: DashboardDocument, +): UiDashboardPreview { + return { + eyebrow: document.schemaVersion, + title: document.metadata.title, + subtitle: document.metadata.subtitle, + telemetry: orderedItems(document.layout.telemetry, document.telemetry).map( + telemetryToUi, + ), + serviceGroups: orderedItems( + document.layout.serviceGroups, + document.serviceGroups, + ).map(serviceGroupToUi), + modules: orderedItems( + document.layout.modules || [], + document.modules || [], + ).map(moduleToUi), + statusItems: orderedItems( + document.layout.statusStrips, + document.statusStrips, + ).flatMap(statusStripToUiItems), + statusStripId: document.layout.statusStrips[0], + }; +} + +function telemetryToUi(card: TelemetryCard): UiTelemetryCard { + return { + id: card.id, + label: card.label, + description: card.description, + icon: card.icon, + value: card.value, + detail: card.detail, + severity: card.severity, + sparkline: card.sparkline, + }; +} + +function serviceGroupToUi(group: ServiceGroup): UiServiceGroup { + return { + id: group.id, + layout: group.layout, + title: group.title, + services: group.services.map(serviceToUi), + }; +} + +function serviceToUi(service: ServiceEntry): UiServiceRow { + return { + id: service.id, + label: service.label, + description: service.description, + icon: service.icon, + severity: service.severity, + detail: service.detail, + link: service.link, + }; +} + +function moduleToUi(module: DashboardModule): UiModuleBlock { + return { + id: module.id, + title: module.title, + label: module.label, + value: module.value, + detail: module.detail, + icon: module.icon, + severity: module.severity, + }; +} + +function statusStripToUiItems(strip: StatusStrip): UiStatusItem[] { + return strip.items.map((item) => statusItemToUi(strip.id, item)); +} + +function statusItemToUi(stripId: string, item: StatusItem): UiStatusItem { + return { + id: `${stripId}:${item.id}`, + label: item.label, + value: item.value, + severity: item.severity, + link: item.link, + }; +} + +function orderedItems(ids: string[], items: T[]): T[] { + const byId = new Map(items.map((item) => [item.id, item])); + return ids.flatMap((id) => { + const item = byId.get(id); + return item ? [item] : []; + }); +} 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/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..9bbebc4 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./app.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Missing React root element"); + +createRoot(root).render( + + + , +); diff --git a/apps/web/src/page.test.tsx b/apps/web/src/page.test.tsx new file mode 100644 index 0000000..2ec5869 --- /dev/null +++ b/apps/web/src/page.test.tsx @@ -0,0 +1,97 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, test } from "vitest"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { AppStateView, resolveDocumentMetadata } from "./App"; + +describe("home page model renderer", () => { + test("renders the active dashboard model from the runtime state", () => { + const body = renderToString( + , + ); + + expect(body).toContain("Operations Console"); + expect(body).toContain("Service Uptime"); + expect(body).toContain("Identity"); + expect(body).toContain("data-model-id=\"service-uptime\""); + expect(body).not.toContain("primary"); + expect(body).not.toContain("secondary"); + }); + + test("renders invalid model state without crashing", () => { + const body = renderToString( + , + ); + + expect(body).toContain("Invalid Dashboard"); + expect(body).toContain("Validation failed"); + expect(body).toContain("Dashboard document is invalid."); + expect(body).toContain("/metadata/title is required"); + }); + + test("derives browser metadata from ready and fallback runtime states", () => { + const ready = resolveDocumentMetadata({ + state: "ready", + document: genericDashboardFixture, + schemaVersion: "dashboard.v1", + currentRevisionId: "revision-1234567890", + }); + const empty = resolveDocumentMetadata({ + state: "empty", + title: "No Dashboard Model", + subtitle: "No active document", + message: "No validated dashboard document is active yet.", + }); + + expect(ready).toEqual({ + title: "Operations Console", + description: "Generic environment", + }); + expect(empty).toEqual({ + title: "No Dashboard Model", + description: "No active document", + }); + }); + + test("renders empty and loading model states without crashing", () => { + const empty = renderToString( + , + ); + const loading = renderToString( + , + ); + + expect(empty).toContain("No Dashboard Model"); + expect(empty).toContain("No active document"); + expect(loading).toContain("Loading Dashboard"); + expect(loading).toContain("Fetching active model"); + }); +}); diff --git a/apps/web/src/server/dev.test.ts b/apps/web/src/server/dev.test.ts new file mode 100644 index 0000000..60031db --- /dev/null +++ b/apps/web/src/server/dev.test.ts @@ -0,0 +1,33 @@ +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", () => { + const packageJson = JSON.parse( + readFileSync(join(process.cwd(), "package.json"), "utf8"), + ) as { scripts?: Record }; + + expect(packageJson.scripts?.dev).toBe("bun src/server/dev.ts"); + }); + + test("proxies dashboard API requests from Vite to the Bun API server", () => { + const server = createDevServerConfig({ + DASHBOARD_DEV_API_TARGET: "http://127.0.0.1:5174", + }); + + expect(server?.proxy?.["/api"]).toMatchObject({ + target: "http://127.0.0.1:5174", + changeOrigin: true, + }); + }); + + test("uses development package export conditions for the Bun API server", () => { + expect(apiServerArgs).toEqual([ + "--conditions=development", + "src/server/index.ts", + ]); + }); +}); diff --git a/apps/web/src/server/dev.ts b/apps/web/src/server/dev.ts new file mode 100644 index 0000000..fa4dc2f --- /dev/null +++ b/apps/web/src/server/dev.ts @@ -0,0 +1,76 @@ +const webHost = process.env.HOST || "0.0.0.0"; +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(); +} + +export function runDevServers(): void { + const children: Array> = []; + let shuttingDown = false; + + function spawn( + label: string, + command: string[], + env: Record = {}, + ): void { + const child = Bun.spawn(command, { + env: { + ...process.env, + ...env, + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + children.push(child); + + void child.exited.then((code) => { + if (shuttingDown) return; + console.error(`${label} exited with status ${code}`); + shutdown(code || 1); + }); + } + + function shutdown(code = 0): void { + if (shuttingDown) return; + shuttingDown = true; + + for (const child of children) { + child.kill(); + } + + void Promise.allSettled(children.map((child) => child.exited)).then(() => { + process.exit(code); + }); + } + + process.on("SIGINT", () => shutdown(0)); + process.on("SIGTERM", () => shutdown(0)); + + spawn("api server", [process.execPath, ...apiServerArgs], { + HOST: apiHost, + PORT: apiPort, + }); + + spawn("vite dev server", [ + process.execPath, + "x", + "vite", + "--host", + webHost, + "--port", + webPort, + ], { + DASHBOARD_DEV_API_TARGET: apiTarget, + }); + + console.info(`Dashboard API proxy target: ${apiTarget}`); +} diff --git a/apps/web/src/server/index.test.ts b/apps/web/src/server/index.test.ts new file mode 100644 index 0000000..af1035d --- /dev/null +++ b/apps/web/src/server/index.test.ts @@ -0,0 +1,77 @@ +import { afterAll, describe, expect, test, vi } from "vitest"; +import { handleRequest } from "./index"; + +describe("server request routing", () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); + + afterAll(() => { + consoleInfo.mockRestore(); + }); + + test("routes dashboard tile batch requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/tiles", { + method: "POST", + body: JSON.stringify({ + tiles: [ + { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + ], + }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + state: "ready", + tiles: [ + { + state: "ready", + tile: { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + }, + ], + }); + }); + + test("rejects non-post dashboard tile batch requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/tiles", { + method: "GET", + }), + ); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + }); + + test("routes dashboard event stream requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/events", { + method: "GET", + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "text/event-stream; charset=utf-8", + ); + }); + + test("rejects non-get dashboard event stream requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/events", { + method: "POST", + }), + ); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + }); +}); diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts new file mode 100644 index 0000000..9cbcbd9 --- /dev/null +++ b/apps/web/src/server/index.ts @@ -0,0 +1,104 @@ +import { extname, normalize } from "node:path"; +import { handleAgentDashboardRoute } from "./routes/agent-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); +const distRoot = `${process.cwd()}/dist`; + +const contentTypes = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".svg", "image/svg+xml"], + [".wasm", "application/wasm"], +]); + +export async function handleRequest(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === "/api/dashboard") { + if (request.method !== "GET") return methodNotAllowed(["GET"]); + 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); + } + + if (url.pathname.startsWith("/api/")) { + return Response.json({ ok: false, message: "Not found" }, { status: 404 }); + } + + return serveStaticAsset(url.pathname); +} + +async function serveStaticAsset(pathname: string): Promise { + const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, ""); + const assetPath = safePath === "/" || safePath === "." ? "/index.html" : safePath; + const file = Bun.file(`${distRoot}${assetPath}`); + + if (await file.exists()) { + return new Response(file, { + headers: contentTypeHeaders(assetPath), + }); + } + + const fallback = Bun.file(`${distRoot}/index.html`); + if (await fallback.exists()) { + return new Response(fallback, { + headers: contentTypeHeaders(".html"), + }); + } + + return new Response("Build output not found", { status: 404 }); +} + +function methodNotAllowed(allowedMethods: string[]): Response { + return Response.json( + { ok: false, message: "Method not allowed" }, + { + status: 405, + headers: { + Allow: allowedMethods.join(", "), + }, + }, + ); +} + +function contentTypeHeaders(pathname: string): HeadersInit { + const contentType = contentTypes.get(extname(pathname)); + return contentType ? { "Content-Type": contentType } : {}; +} + +if (import.meta.main) { + Bun.serve({ + hostname: host, + port, + fetch: handleRequest, + }); + + console.info(`Dimension Lab website listening on http://${host}:${port}`); +} diff --git a/apps/web/src/server/routes/agent-dashboard.test.ts b/apps/web/src/server/routes/agent-dashboard.test.ts new file mode 100644 index 0000000..a3a6022 --- /dev/null +++ b/apps/web/src/server/routes/agent-dashboard.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; +import { handleAgentDashboardRoute } from "./agent-dashboard"; + +describe("agent dashboard API route", () => { + test("delegates unauthorized requests to the existing agent handler", async () => { + const response = await handleAgentDashboardRoute( + new Request("http://localhost/api/agent/dashboard", { method: "POST" }), + ); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/web/src/server/routes/agent-dashboard.ts b/apps/web/src/server/routes/agent-dashboard.ts new file mode 100644 index 0000000..1ce77da --- /dev/null +++ b/apps/web/src/server/routes/agent-dashboard.ts @@ -0,0 +1,5 @@ +import { handleAgentDashboardRequest } from "$lib/server/agent-config"; + +export function handleAgentDashboardRoute(request: Request): Promise { + return handleAgentDashboardRequest(request); +} 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/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..2e6c10b --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare module "*.css" {} diff --git a/apps/web/static/mockServiceWorker.js b/apps/web/static/mockServiceWorker.js new file mode 100644 index 0000000..33dde9e --- /dev/null +++ b/apps/web/static/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/web/tests/e2e/dashboard.spec.ts b/apps/web/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..3eb0944 --- /dev/null +++ b/apps/web/tests/e2e/dashboard.spec.ts @@ -0,0 +1,381 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +const linkedServiceIds = [ + "vaultwarden", + "forgejo", + "wiki", + "aws-start", + "adguard-primary", + "adguard-secondary", + "grafana", + "uptime-kuma", + "prometheus", + "backrest", + "n8n", + "open-webui", + "comfyui", + "models", + "adminer", + "assistant", + "suna", + "cockpit-infra", + "cockpit-gpu", + "cockpit-network-core", + "forgejo-ssh-relay", +]; + +test.describe("dashboard page QA gate", () => { + test("renders the model-driven dashboard on desktop", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop"); + + await page.goto("/"); + await waitForDashboardReady(page); + + await expect(page.getByRole("main")).toHaveAttribute( + "aria-labelledby", + /title/, + ); + await expect( + page.getByRole("heading", { level: 1, name: "System Overview" }), + ).toBeVisible(); + await expect(page.getByText("Infra RAM")).toBeVisible(); + await expect(page.getByRole("link", { name: /Vaultwarden/i })).toBeVisible(); + await expect(page.locator("[data-model-id='vaultwarden']")).toBeVisible(); + await expect(page.locator(".service-row .icon-glyph svg").first()).toBeVisible(); + + const bodyBox = await page.locator("body").boundingBox(); + expect(bodyBox?.width).toBeGreaterThan(1000); + + await expect(page).toHaveScreenshot("dashboard-desktop.png", { + fullPage: true, + }); + }); + + test("fits the operational dashboard into a 1470 by 956 viewport", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop"); + + await page.setViewportSize({ width: 1470, height: 956 }); + await page.goto("/"); + await waitForDashboardReady(page); + + const metrics = await page.evaluate(() => { + const footer = document.querySelector("[data-model-id='footer-status']"); + const runtime = document.querySelector("[data-model-id='runtime-health']"); + const visibleItems = [ + ...document.querySelectorAll(".telemetry-card"), + ...document.querySelectorAll(".service-row"), + ...document.querySelectorAll(".footer-cell"), + ].map((element) => { + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + height: rect.height, + id: element.getAttribute("data-model-id"), + top: rect.top, + width: rect.width, + }; + }); + + return { + clippedItems: visibleItems.filter((item) => + item.top < 0 || + item.bottom > window.innerHeight || + item.width <= 0 || + item.height <= 0 + ), + footerCellCount: document.querySelectorAll(".footer-cell").length, + scrollWidth: document.documentElement.scrollWidth, + scrollHeight: document.documentElement.scrollHeight, + serviceRowCount: document.querySelectorAll(".service-row").length, + telemetryCardCount: document.querySelectorAll(".telemetry-card").length, + footerBottom: footer?.getBoundingClientRect().bottom ?? 0, + runtimeBottom: runtime?.getBoundingClientRect().bottom ?? 0, + }; + }); + + expect(metrics.scrollWidth).toBeLessThanOrEqual(1470); + expect(metrics.scrollHeight).toBeLessThanOrEqual(956); + expect(metrics.runtimeBottom).toBeLessThanOrEqual(956); + expect(metrics.footerBottom).toBeLessThanOrEqual(956); + expect(metrics.telemetryCardCount).toBe(16); + 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"); + + await page.goto("/"); + await waitForDashboardReady(page); + + const bookmarkDensity = await page.evaluate(() => { + const panelBody = document.querySelector(".service-panel .panel__body"); + const rows = Array.from(document.querySelectorAll(".service-panel .service-row")); + const first = rows[0]; + const second = rows[1]; + if (!panelBody || !first || !second) { + throw new Error("expected at least two bookmark rows"); + } + + const bodyStyle = window.getComputedStyle(panelBody); + const rowStyle = window.getComputedStyle(first); + const firstRect = first.getBoundingClientRect(); + const secondRect = second.getBoundingClientRect(); + + return { + backgroundColor: rowStyle.backgroundColor, + borderBottomWidth: Number.parseFloat(rowStyle.borderBottomWidth), + borderLeftWidth: Number.parseFloat(rowStyle.borderLeftWidth), + borderRightWidth: Number.parseFloat(rowStyle.borderRightWidth), + columnGap: Number.parseFloat(rowStyle.columnGap), + paddingBottom: Number.parseFloat(rowStyle.paddingBottom), + paddingTop: Number.parseFloat(rowStyle.paddingTop), + panelGap: Number.parseFloat(bodyStyle.rowGap), + rowGap: secondRect.top - firstRect.bottom, + }; + }); + + expect(bookmarkDensity.panelGap).toBe(0); + expect(bookmarkDensity.rowGap).toBeLessThanOrEqual(1); + expect(bookmarkDensity.paddingTop).toBeLessThanOrEqual(3); + expect(bookmarkDensity.paddingBottom).toBeLessThanOrEqual(3); + expect(bookmarkDensity.columnGap).toBeLessThanOrEqual(4); + expect(bookmarkDensity.borderBottomWidth).toBeGreaterThanOrEqual(1); + expect(bookmarkDensity.borderLeftWidth).toBe(0); + expect(bookmarkDensity.borderRightWidth).toBe(0); + expect(bookmarkDensity.backgroundColor).toBe("rgba(0, 0, 0, 0)"); + }); + + test("balances dashboard typography at the target viewport", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop"); + + await page.setViewportSize({ width: 1470, height: 956 }); + await page.goto("/"); + await waitForDashboardReady(page); + + const typeScale = await page.evaluate(() => { + const fontSize = (selector: string) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`missing ${selector}`); + return Number.parseFloat(window.getComputedStyle(element).fontSize); + }; + + return { + h1: fontSize("h1"), + panelTitle: fontSize(".service-panel h2"), + serviceDescription: fontSize(".service-row p"), + serviceLabel: fontSize(".service-row h3"), + telemetryLabel: fontSize(".telemetry-card h3"), + telemetryValue: fontSize(".telemetry-card strong"), + }; + }); + + expect(typeScale.serviceLabel).toBeGreaterThanOrEqual(11.4); + expect(typeScale.serviceDescription).toBeGreaterThanOrEqual(8.8); + expect(typeScale.h1).toBeLessThanOrEqual(49); + expect(typeScale.panelTitle).toBeLessThanOrEqual(28); + expect(typeScale.telemetryLabel).toBeLessThanOrEqual(9.5); + expect(typeScale.telemetryValue).toBeLessThanOrEqual(36); + }); + + test("keeps the first screen usable on mobile", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile"); + + await page.goto("/"); + await waitForDashboardReady(page); + + await expect( + page.getByRole("heading", { level: 1, name: "System Overview" }), + ).toBeVisible(); + await expect(page.getByLabel("Service groups")).toBeVisible(); + await expect(page.locator(".service-row .icon-glyph svg").first()).toBeVisible(); + + await expect(page).toHaveScreenshot("dashboard-mobile.png", { + fullPage: true, + }); + }); + + test("exposes usable landmarks and a visible keyboard focus state", async ({ + page, + }) => { + await page.goto("/"); + 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"); + + const focused = page.locator(":focus"); + await expect(focused).toHaveAttribute("data-model-id", serviceId); + + const focusBoxShadow = await focused.evaluate((element) => { + return window.getComputedStyle(element).boxShadow; + }); + expect(focusBoxShadow).not.toBe("none"); + } + }); + + test("passes automated accessibility checks", async ({ page }) => { + await page.goto("/"); + await waitForDashboardReady(page); + + const results = await new AxeBuilder({ page }).analyze(); + 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" }); + + await page.goto("/"); + await waitForDashboardReady(page); + const durations = await page.evaluate(() => { + const element = document.createElement("div"); + element.style.animation = "qa-motion-check 10s infinite"; + element.style.transition = "opacity 10s linear"; + document.body.append(element); + + const styles = window.getComputedStyle(element); + return { + animation: styles.animationDuration, + transition: styles.transitionDuration, + }; + }); + + expect(cssDurationToMilliseconds(durations.animation)).toBeLessThanOrEqual( + 0.01, + ); + expect(cssDurationToMilliseconds(durations.transition)).toBeLessThanOrEqual( + 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 { + await expect( + page.getByRole("heading", { level: 1, name: "System Overview" }), + ).toBeVisible(); + await expect(page.locator("[data-model-id='vaultwarden']")).toBeVisible(); +} + +function cssDurationToMilliseconds(duration: string): number { + if (duration.endsWith("ms")) return Number.parseFloat(duration); + if (duration.endsWith("s")) return Number.parseFloat(duration) * 1000; + return Number.NaN; +} 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/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..ceff746 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, configDefaults } from "vitest/config"; +import type { UserConfig } from "vite"; + +export function createDevServerConfig( + env: NodeJS.ProcessEnv = process.env, +): UserConfig["server"] { + const apiTarget = env.DASHBOARD_DEV_API_TARGET; + if (!apiTarget) return undefined; + + return { + proxy: { + "/api": { + target: apiTarget, + changeOrigin: true, + }, + }, + }; +} + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + $lib: fileURLToPath(new URL("./src/lib", import.meta.url)), + }, + }, + server: createDevServerConfig(), + test: { + exclude: [...configDefaults.exclude, "tests/e2e/**"], + }, +}); diff --git a/bun.lock b/bun.lock index 6bf41c5..d6915d0 100644 --- a/bun.lock +++ b/bun.lock @@ -4,33 +4,171 @@ "workspaces": { "": { "name": "dimensionlab-website", + "devDependencies": { + "turbo": "^2.5.0", + }, + }, + "apps/web": { + "name": "@dimensionlab/web", + "version": "0.0.1", "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": { - "@sveltejs/adapter-node": "^5.5.4", - "@sveltejs/kit": "^2.65.2", - "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@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", - "svelte": "^5.56.3", - "svelte-check": "^4.6.0", + "msw": "^2.14.6", + "shadcn": "^4.11.0", + "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=="], + + "@axe-core/playwright": ["@axe-core/playwright@4.11.3", "", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@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=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], @@ -38,57 +176,85 @@ "@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=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.9", "", {}, "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], + + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], + + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + + "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -100,11 +266,231 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], - "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.127.0", "", { "os": "android", "cpu": "arm" }, "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.127.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.127.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.127.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.127.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.127.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.127.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.127.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.127.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.127.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.127.0", "", { "os": "linux", "cpu": "none" }, "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.127.0", "", { "os": "linux", "cpu": "none" }, "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.127.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.127.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.127.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.127.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ=="], + + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.127.0", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.127.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.127.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], + + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], + + "@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.10", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.0", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-label": "2.1.10", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.10", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-toggle": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-separator": "1.1.10", "@radix-ui/react-toggle-group": "1.1.13" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.2", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], @@ -138,12 +524,6 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="], - - "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="], - - "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="], - "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ=="], @@ -196,37 +576,119 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + "@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.6", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.6" } }, "sha512-XCJy+f0DFOiCgUU9knRDlLDxVFI+AAQ3/wE/NF85zB9iDPPS2DwkSN+mas3zDgHt66zhN8Cq3/UiyCDUweV9Zw=="], - "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.4", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ=="], + "@storybook/addon-vitest": ["@storybook/addon-vitest@10.4.6", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.4.6", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-VvskHge0GZy86LG6kcY5Ww34z8rDV8JBxqSdUpcJVsWfIvyX6MfAbqI76LlereSyBIJGZJZsqaLwRXsQoVY+0Q=="], - "@sveltejs/kit": ["@sveltejs/kit@2.65.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-ZIkyEmxT1gcq50Opn1ZIIx6vc/yt2zNN0rF5hS6op95gqHtNw8QMKDhjJI+RyjMcbvECRw+FzEeAoBe/MOz9AA=="], + "@storybook/builder-vite": ["@storybook/builder-vite@10.4.6", "", { "dependencies": { "@storybook/csf-plugin": "10.4.6", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.4.6", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-BHBtD81HiXUiDQz/CaFynLtWmm7AFUQn8VnXuHipZ8KlnUANopa4yqdVuy/Gwz8ub254uFI5NMZsW/KlgWNgNg=="], - "@sveltejs/load-config": ["@sveltejs/load-config@0.1.1", "", {}, "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA=="], + "@storybook/csf-plugin": ["@storybook/csf-plugin@10.4.6", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.4.6", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA=="], - "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="], + "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], + + "@storybook/icons": ["@storybook/icons@2.0.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KZBCpXsshAIjczYNXR/rlxEtCUX/eAbpFNwKi8bcOomrLA4t/SyPz5RF+lVPO2oZBUE4sAkt43mfJUevQDSEEw=="], + + "@storybook/react": ["@storybook/react@10.4.6", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.4.6", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.6", "typescript": ">= 4.9.x" }, "optionalPeers": ["@types/react", "@types/react-dom", "typescript"] }, "sha512-9Y7YecrVFe1/01KYjfOLxVqTg2Aq+IO6TEv6sC2U0PfD0AWCSCmQ91QqgBpN/XW4aFFWoiZNinyXMUlU8zxy2w=="], + + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.4.6", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.6" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg=="], + + "@storybook/react-vite": ["@storybook/react-vite@10.4.6", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.4.6", "@storybook/react": "10.4.6", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.6", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-0arEQtybqGYXHbXpTot+Wv9YtG+V5Vp43QayXavPKQ20M8mpEzhyCPKd0EhqMGSC1Z1UEt0hm365WUBhI9LfKA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@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=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/doctrine": ["@types/doctrine@0.0.9", "", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], @@ -242,80 +704,366 @@ "@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=="], + "@webcontainer/env": ["@webcontainer/env@1.1.1", "", {}, "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + + "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.38", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.376", "", {}, "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "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=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "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=="], - "esrap": ["esrap@2.2.11", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], + + "hono": ["hono@4.12.26", "", {}, "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "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=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -342,47 +1090,237 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-releases": ["node-releases@2.0.48", "", {}, "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "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=="], + + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + + "oxc-parser": ["oxc-parser@0.127.0", "", { "dependencies": { "@oxc-project/types": "^0.127.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.127.0", "@oxc-parser/binding-android-arm64": "0.127.0", "@oxc-parser/binding-darwin-arm64": "0.127.0", "@oxc-parser/binding-darwin-x64": "0.127.0", "@oxc-parser/binding-freebsd-x64": "0.127.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", "@oxc-parser/binding-linux-arm64-musl": "0.127.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", "@oxc-parser/binding-linux-x64-gnu": "0.127.0", "@oxc-parser/binding-linux-x64-musl": "0.127.0", "@oxc-parser/binding-openharmony-arm64": "0.127.0", "@oxc-parser/binding-wasm32-wasi": "0.127.0", "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", "@oxc-parser/binding-win32-x64-msvc": "0.127.0" } }, "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA=="], + + "oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + + "playwright": ["playwright@1.61.0", "", { "dependencies": { "playwright-core": "1.61.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ=="], + + "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "radix-ui": ["radix-ui@1.6.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-accessible-icon": "1.1.10", "@radix-ui/react-accordion": "1.2.14", "@radix-ui/react-alert-dialog": "1.1.17", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-aspect-ratio": "1.1.10", "@radix-ui/react-avatar": "1.2.0", "@radix-ui/react-checkbox": "1.3.5", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-context-menu": "2.3.1", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-dropdown-menu": "2.1.18", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-form": "0.1.10", "@radix-ui/react-hover-card": "1.1.17", "@radix-ui/react-label": "2.1.10", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-menubar": "1.1.18", "@radix-ui/react-navigation-menu": "1.2.16", "@radix-ui/react-one-time-password-field": "0.1.10", "@radix-ui/react-password-toggle-field": "0.1.5", "@radix-ui/react-popover": "1.1.17", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-progress": "1.1.10", "@radix-ui/react-radio-group": "1.4.1", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-scroll-area": "1.2.12", "@radix-ui/react-select": "2.3.1", "@radix-ui/react-separator": "1.1.10", "@radix-ui/react-slider": "1.4.1", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-switch": "1.3.1", "@radix-ui/react-tabs": "1.1.15", "@radix-ui/react-toast": "1.2.17", "@radix-ui/react-toggle": "1.1.12", "@radix-ui/react-toggle-group": "1.1.13", "@radix-ui/react-toolbar": "1.1.13", "@radix-ui/react-tooltip": "1.2.10", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-escape-keydown": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], + + "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "rollup": ["rollup@4.62.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.0", "@rollup/rollup-android-arm64": "4.62.0", "@rollup/rollup-darwin-arm64": "4.62.0", "@rollup/rollup-darwin-x64": "4.62.0", "@rollup/rollup-freebsd-arm64": "4.62.0", "@rollup/rollup-freebsd-x64": "4.62.0", "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", "@rollup/rollup-linux-arm-musleabihf": "4.62.0", "@rollup/rollup-linux-arm64-gnu": "4.62.0", "@rollup/rollup-linux-arm64-musl": "4.62.0", "@rollup/rollup-linux-loong64-gnu": "4.62.0", "@rollup/rollup-linux-loong64-musl": "4.62.0", "@rollup/rollup-linux-ppc64-gnu": "4.62.0", "@rollup/rollup-linux-ppc64-musl": "4.62.0", "@rollup/rollup-linux-riscv64-gnu": "4.62.0", "@rollup/rollup-linux-riscv64-musl": "4.62.0", "@rollup/rollup-linux-s390x-gnu": "4.62.0", "@rollup/rollup-linux-x64-gnu": "4.62.0", "@rollup/rollup-linux-x64-musl": "4.62.0", "@rollup/rollup-openbsd-x64": "4.62.0", "@rollup/rollup-openharmony-arm64": "4.62.0", "@rollup/rollup-win32-arm64-msvc": "4.62.0", "@rollup/rollup-win32-ia32-msvc": "4.62.0", "@rollup/rollup-win32-x64-gnu": "4.62.0", "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA=="], - "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shadcn": ["shadcn@4.11.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -392,13 +1330,41 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "storybook": ["storybook@10.4.6", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A=="], + + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svelte": ["svelte@5.56.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA=="], + "systeminformation": ["systeminformation@5.31.7", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw=="], - "svelte-check": ["svelte-check@4.6.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "0.1.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -408,33 +1374,223 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@7.4.3", "", { "dependencies": { "tldts-core": "^7.4.3" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg=="], + + "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "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=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], - "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uplot": ["uplot@1.6.32", "", {}, "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "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.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "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=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], + "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], - "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=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "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=="], + + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "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=="], + + "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=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "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=="], + + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "shadcn/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "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=="], + + "@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=="], + + "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@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=="], @@ -480,56 +1636,80 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "@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=="], - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "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=="], + + "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], } } diff --git a/docs/superpowers/plans/2026-06-19-react-runtime-migration.md b/docs/superpowers/plans/2026-06-19-react-runtime-migration.md new file mode 100644 index 0000000..f79a1d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-react-runtime-migration.md @@ -0,0 +1,592 @@ +# React Runtime Migration 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:** Replace the SvelteKit dashboard app with a React-based Vite app and Bun production server while preserving model-driven dashboard behavior and reusable presentation boundaries. + +**Architecture:** The browser runtime becomes React mounted from `src/main.tsx`. A Bun server at `src/server/index.ts` serves the static React build and exposes JSON API routes for dashboard reads and agent dashboard mutations. Existing model, persistence, datasource, and agent-config modules remain framework-agnostic TypeScript with import path updates as needed. + +**Tech Stack:** Bun, Vite, React, React DOM, TypeScript, Tailwind CSS v4, shadcn/ui, Vitest, Storybook React Vite, Playwright, Drizzle ORM, Bun SQLite, MSW, uPlot, Iconify React. + +--- + +## File Structure + +- Create `index.html`: Vite app shell with `
` and `/src/main.tsx`. +- Create `src/main.tsx`: React DOM bootstrap and global CSS import. +- Create `src/App.tsx`: Dashboard fetch, refresh interval, ready/empty/loading/invalid rendering. +- Create `src/App.test.tsx`: React server-rendering and hook behavior tests for dashboard states. +- Create `src/server/index.ts`: Bun HTTP server, static asset serving, API router, production entry. +- Create `src/server/routes/dashboard.ts`: `GET /api/dashboard` runtime loader and datasource resolver. +- Create `src/server/routes/agent-dashboard.ts`: `POST /api/agent/dashboard` adapter for `handleAgentDashboardRequest`. +- Create `src/server/routes/dashboard.test.ts`: dashboard API state and datasource-disable tests. +- Create `src/server/routes/agent-dashboard.test.ts`: agent API delegation/auth tests. +- Create `src/lib/ui/components/*.tsx`: React ports of current reusable Svelte components. +- Create `src/lib/ui/components/styles.css`: component CSS migrated from Svelte style blocks. +- Create `components.json`: shadcn/ui configuration for Vite, Radix, Nova, Tailwind v4, and `$lib` aliases. +- Create `src/lib/components/ui/*.tsx`: selected shadcn primitives, not the full registry. +- Create `src/lib/utils.ts`: `cn()` helper for shadcn and dashboard components. +- Replace `src/lib/ui/components/render.test.ts`: React `renderToString` component tests. +- Replace `src/lib/ui/stories/*.stories.svelte`: React `.stories.tsx` stories. +- Modify `.storybook/main.ts`: use `@storybook/react-vite` and React story globs. +- Modify `.storybook/preview.ts`: use React Storybook types and keep MSW setup/global CSS. +- Modify `vite.config.ts`: use React and Tailwind plugins, alias `$lib` to `src/lib`, build client, and keep Vitest config. +- Modify `tsconfig.json`: remove `.svelte-kit` inheritance, enable JSX, and define path aliases. +- Modify `package.json`: swap Svelte/SvelteKit dependencies for React/Tailwind/shadcn tooling and update scripts. +- Modify `playwright.config.ts`: build React client and Bun server before e2e. +- Modify `Containerfile`: copy React/Bun build artifacts and keep `bun build/index.js` command. +- Modify `README.md`: document React runtime, Bun server, scripts, QA gate, deployment. +- Remove `svelte.config.js`, `src/app.html`, `src/routes/**`, and all `.svelte` files after replacements pass. + +## Task 1: React Toolchain And Typecheck Scaffold + +**Files:** +- Modify: `package.json` +- Modify: `bun.lock` +- Modify: `tsconfig.json` +- Modify: `vite.config.ts` +- Create: `index.html` +- Create: `src/main.tsx` +- Create: `src/App.tsx` +- Create: `src/App.test.tsx` + +- [ ] **Step 1: Write the failing React scaffold test** + +Create `src/App.test.tsx`: + +```tsx +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"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun run test:unit src/App.test.tsx` + +Expected: FAIL because React dependencies and `src/App.tsx` do not exist. + +- [ ] **Step 3: Add React dependencies and scaffold files** + +Update `package.json` scripts and dependencies: + +```json +{ + "scripts": { + "dev": "vite --host 0.0.0.0", + "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" + } +} +``` + +Install React, Tailwind, and shadcn packages with Bun so `bun.lock` updates: + +```sh +bun add @iconify/react @vitejs/plugin-react react react-dom +bun add class-variance-authority clsx lucide-react radix-ui tailwind-merge tw-animate-css @fontsource-variable/geist +bun add -d @storybook/react-vite @tailwindcss/vite @types/react @types/react-dom shadcn tailwindcss +``` + +Create `index.html`: + +```html + + + + + + Dimension Lab + + +
+ + + +``` + +Create minimal `src/App.tsx`: + +```tsx +import type { DashboardRuntimeState } from "$lib/server/dashboard"; + +export function AppStateView({ dashboard }: { dashboard: DashboardRuntimeState }) { + if (dashboard.state === "ready") { + return
{dashboard.document.metadata.title}
; + } + + return ( +
+

{dashboard.title}

+

{dashboard.subtitle}

+

{dashboard.message}

+
+ ); +} + +export default function App() { + return ( + + ); +} +``` + +Create `src/main.tsx`: + +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./app.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Missing React root element"); + +createRoot(root).render( + + + , +); +``` + +Update `tsconfig.json` with React JSX and path aliases. + +Update `vite.config.ts` to use `@vitejs/plugin-react`, `@tailwindcss/vite`, +and the `$lib` alias. Initialize shadcn with: + +```sh +bunx --bun shadcn@latest init --template vite --base radix --preset nova --yes --css-variables +bunx --bun shadcn@latest add badge card progress separator skeleton alert +``` + +Do not run `bunx --bun shadcn@latest add --all`; the dashboard should only +vendor primitives it actually uses. + +- [ ] **Step 4: Run checks for the scaffold** + +Run: `bun run test:unit src/App.test.tsx && bun run check` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add package.json bun.lock tsconfig.json vite.config.ts index.html src/main.tsx src/App.tsx src/App.test.tsx +git commit -m "build: add react vite scaffold" +``` + +## Task 2: Bun Server And Dashboard API + +**Files:** +- Create: `src/server/index.ts` +- Create: `src/server/routes/dashboard.ts` +- Create: `src/server/routes/dashboard.test.ts` +- Create: `src/server/routes/agent-dashboard.ts` +- Create: `src/server/routes/agent-dashboard.test.ts` +- Modify: `src/App.tsx` +- Modify: `playwright.config.ts` + +- [ ] **Step 1: Write failing API route tests** + +Create `src/server/routes/dashboard.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { loadDashboardResponse } from "./dashboard"; + +describe("dashboard API route", () => { + test("returns ready dashboard runtime state from the existing model loader", async () => { + const response = await loadDashboardResponse({ + disableLiveDatasources: true, + refreshSeedDocument: true, + seedIfEmpty: true, + }); + + expect(response.state).toBe("ready"); + if (response.state !== "ready") throw new Error("expected ready dashboard"); + expect(response.document.metadata.title).toContain("Dimension Lab"); + }); +}); +``` + +Create `src/server/routes/agent-dashboard.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { handleAgentDashboardRoute } from "./agent-dashboard"; + +describe("agent dashboard API route", () => { + test("delegates unauthorized requests to the existing agent handler", async () => { + const response = await handleAgentDashboardRoute( + new Request("http://localhost/api/agent/dashboard", { method: "POST" }), + ); + + expect(response.status).toBe(401); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun run test:unit src/server/routes/dashboard.test.ts src/server/routes/agent-dashboard.test.ts` + +Expected: FAIL because the route modules do not exist. + +- [ ] **Step 3: Implement server route modules and server entry** + +Implement `loadDashboardResponse()` by calling `loadDashboardRuntime()` and +`resolveDashboardDatasources()` exactly like the current SvelteKit load +function. Implement `handleAgentDashboardRoute()` by returning +`handleAgentDashboardRequest(request)`. Implement `src/server/index.ts` with +Bun.serve routes for `/api/dashboard`, `/api/agent/dashboard`, static Vite +assets, and SPA fallback to `index.html`. + +- [ ] **Step 4: Update React app to fetch `/api/dashboard`** + +`src/App.tsx` should export `AppStateView` for tests and make the default +`App` fetch dashboard state with `useEffect`. It should clear refresh timers +when state changes and on unmount. + +- [ ] **Step 5: Run route and app tests** + +Run: + +```sh +bun run test:unit src/server/routes/dashboard.test.ts src/server/routes/agent-dashboard.test.ts src/App.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```sh +git add src/server src/App.tsx src/App.test.tsx playwright.config.ts +git commit -m "feat(server): add bun dashboard api" +``` + +## Task 3: React UI Component Library + +**Files:** +- Create: `src/lib/ui/components/*.tsx` +- Create: `src/lib/ui/components/styles.css` +- Modify: `src/lib/ui/components/render.test.ts` +- Modify: `src/lib/ui/index.ts` +- Keep: `src/lib/ui/types.ts` +- Keep: `src/lib/ui/model-renderer.ts` +- Keep: `src/lib/ui/fixtures.ts` + +- [ ] **Step 1: Replace Svelte SSR tests with failing React render tests** + +Rewrite `src/lib/ui/components/render.test.ts` to import React components and +`renderToString` from `react-dom/server`. Keep the current assertions for: + +- dashboard fixture content +- generic secondary fixture content +- optional service/status links +- stable model IDs +- progress bar behavior +- uPlot chart surface marker +- native attributes on Button and IconButton + +Run: `bun run test:unit src/lib/ui/components/render.test.ts` + +Expected: FAIL because React component files do not exist yet. + +- [ ] **Step 2: Port atomic components** + +Create React equivalents for `Badge`, `Button`, `IconGlyph`, `IconButton`, +`ProgressMeter`, `Separator`, `Sparkline`, `SignalTrace`, `LineChart`, and +`StatusBadge`. Preserve class names and `data-*` attributes from Svelte. + +Run: `bun run test:unit src/lib/ui/components/render.test.ts` + +Expected: remaining FAILs only for dashboard composite components. + +- [ ] **Step 3: Port layout and card components** + +Create React equivalents for `Panel`, `CornerBracketFrame`, `GridFrame`, +`DiagonalStripeField`, `ModuleCard`, `TelemetryCard`, `TelemetryGrid`, +`FooterCell`, `FooterStatusCell`, and `StatusStrip`. + +Run: `bun run test:unit src/lib/ui/components/render.test.ts` + +Expected: remaining FAILs only for service/dashboard shell components. + +- [ ] **Step 4: Port service and dashboard shell components** + +Create React equivalents for `ServiceRow`, `ServicePanel`, +`ServiceGroupPanel`, `SystemState`, `DashboardHeader`, `DashboardFrame`, +`TelemetryStrip`, and `WeatherModule`. + +Run: `bun run test:unit src/lib/ui/components/render.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Export React components** + +Update `src/lib/ui/index.ts` to export `.tsx` React components and continue +exporting fixtures, renderer, and UI types. + +Run: + +```sh +bun run test:unit src/lib/ui/components/render.test.ts src/lib/ui/model-renderer.test.ts src/lib/ui/content-boundary.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```sh +git add src/lib/ui/components src/lib/ui/index.ts +git commit -m "feat(ui): port dashboard components to react" +``` + +## Task 4: React Dashboard App Rendering + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/App.test.tsx` +- Modify: `src/app.css` +- Delete after `src/page.test.tsx` passes: `src/routes/page.test.ts` +- Create: `src/page.test.tsx` + +- [ ] **Step 1: Write failing React page tests** + +Create `src/page.test.tsx` with React `renderToString` assertions equivalent to +the current Svelte `src/routes/page.test.ts`: + +- ready dashboard renders model content +- invalid model state renders validation errors +- empty and loading states render without crashing + +Run: `bun run test:unit src/page.test.tsx` + +Expected: FAIL until `AppStateView` uses the React `DashboardFrame` and +`SystemState` components. + +- [ ] **Step 2: Implement app state rendering** + +Use `dashboardDocumentToUiDashboard()` and `DashboardFrame` for ready state. +Use `SystemState` for empty/loading/invalid states. Preserve state shell CSS +and validation error list markup. + +- [ ] **Step 3: Run page tests** + +Run: `bun run test:unit src/page.test.tsx src/App.test.tsx` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/App.tsx src/App.test.tsx src/page.test.tsx src/app.css +git commit -m "feat(app): render dashboard with react" +``` + +## Task 5: React Storybook + +**Files:** +- Modify: `.storybook/main.ts` +- Modify: `.storybook/preview.ts` +- Create: `src/lib/ui/stories/*.stories.tsx` +- Delete after replacement: `src/lib/ui/stories/*.stories.svelte` +- Delete after replacement: `src/lib/ui/stories/FocusPreview.svelte` +- Modify: `src/lib/ui/storybook.test.ts` + +- [ ] **Step 1: Update storybook boundary test first** + +Change `src/lib/ui/storybook.test.ts` so it requires React `.stories.tsx` +files and rejects `.stories.svelte` files. + +Run: `bun run test:unit src/lib/ui/storybook.test.ts` + +Expected: FAIL while Svelte stories still exist. + +- [ ] **Step 2: Configure React Storybook** + +Update `.storybook/main.ts` to use `@storybook/react-vite`, React story globs, +and the existing addons. Update `.storybook/preview.ts` type imports to React +Storybook while preserving global CSS, MSW setup, backgrounds, controls, and +fullscreen layout. + +- [ ] **Step 3: Port stories to React** + +Create `.stories.tsx` files for each existing Svelte story. Import React +components from `src/lib/ui` and generic story data from +`src/lib/ui/stories/story-data.ts`. + +- [ ] **Step 4: Remove Svelte stories and run Storybook checks** + +Run: + +```sh +bun run test:unit src/lib/ui/storybook.test.ts +bun run build-storybook +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add .storybook src/lib/ui/stories src/lib/ui/storybook.test.ts +git commit -m "feat(storybook): migrate stories to react" +``` + +## Task 6: Remove SvelteKit Runtime + +**Files:** +- Delete: `svelte.config.js` +- Delete: `src/app.html` +- Delete: `src/routes/**` +- Delete: all remaining `*.svelte` +- Modify: `package.json` +- Modify: `bun.lock` +- Modify: `README.md` +- Modify: `Containerfile` +- Modify: `playwright.config.ts` +- Modify: `src/lib/presentation-boundary.test.ts` + +- [ ] **Step 1: Write/adjust cleanup tests** + +Add assertions to presentation or storybook boundary tests that no `.svelte` +files remain under `src/`. + +Run: `bun run test:unit src/lib/presentation-boundary.test.ts src/lib/ui/storybook.test.ts` + +Expected: FAIL while Svelte files remain. + +- [ ] **Step 2: Delete Svelte runtime and dependencies** + +Remove all Svelte files and Svelte dependencies. Run `bun install +--frozen-lockfile` only after `package.json` and `bun.lock` are consistent, or +run `bun remove` commands to update both together: + +```sh +bun remove @iconify/svelte @storybook/addon-svelte-csf @storybook/sveltekit @sveltejs/adapter-node @sveltejs/kit @sveltejs/vite-plugin-svelte svelte svelte-check +``` + +- [ ] **Step 3: Update docs, container, and e2e build command** + +README should describe React, Vite, Bun server, and unchanged persistence. +`Containerfile` should copy the Vite client output and Bun server output. +`playwright.config.ts` should build and start the Bun server. + +- [ ] **Step 4: Run cleanup checks** + +Run: + +```sh +rg -n "\\.svelte|svelte" package.json src .storybook vite.config.ts tsconfig.json README.md Containerfile +bun run check +bun run test:unit +``` + +Expected: `rg` finds no Svelte app/runtime references except historical docs in +the committed design/plan, and checks pass. + +- [ ] **Step 5: Commit** + +```sh +git add -A +git commit -m "refactor: remove svelte runtime" +``` + +## Task 7: QA Gate, PR, Review, And Merge + +**Files:** +- Modify only files needed to fix failures found by this task. + +- [ ] **Step 1: Run full QA gate** + +Run: `bun run test:qa` + +Expected: PASS for check, unit tests, production build, Storybook build, and +Playwright desktop/mobile tests. + +- [ ] **Step 2: Inspect current diff** + +Run: + +```sh +git status --short +git diff --stat main...HEAD +git diff --name-only main...HEAD +``` + +Expected: only React migration files and docs changed. + +- [ ] **Step 3: Push and open ready PR** + +Run: + +```sh +git push -u origin codex/react-migration +``` + +Open a ready PR against `main` with title: + +```text +refactor: migrate dashboard runtime to react +``` + +- [ ] **Step 4: Independent review** + +Dispatch an independent reviewer to inspect the issue goal, spec, plan, and PR +diff in code-review mode. Blocking findings must be fixed on the same branch. + +- [ ] **Step 5: Fix review findings and re-run QA** + +For each blocking finding, write or update the relevant failing test first, +make the minimal fix, and run the focused test plus `bun run test:qa`. + +- [ ] **Step 6: Merge only after green checks and no blocking review findings** + +Merge the PR into `main`, sync the worktree back to `main`, and mark the goal +complete only after the completion criteria in the spec are proven by current +state. + +## Plan Self-Review + +- Spec coverage: Tasks cover React scaffold, Bun API server, UI component + migration, app rendering, Storybook migration, Svelte removal, QA, PR, + independent review, and merge. +- Red-flag scan: The plan has no incomplete-work markers and no unspecified + acceptance gates. +- Type consistency: Public names used across tasks are `AppStateView`, + `loadDashboardResponse`, and `handleAgentDashboardRoute`. 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-19-react-runtime-migration-design.md b/docs/superpowers/specs/2026-06-19-react-runtime-migration-design.md new file mode 100644 index 0000000..17a8fa6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-react-runtime-migration-design.md @@ -0,0 +1,177 @@ +# React Runtime Migration Design + +## Context + +The current Dimension Lab website is a SvelteKit application. It uses Bun, +Vite, Svelte 5, SvelteKit server routes, Svelte Storybook stories, and +Svelte SSR component tests. The app has one dashboard route, one agent +configuration API route, reusable Svelte UI components, typed dashboard model +data, Drizzle-backed SQLite persistence, datasource resolution, Playwright +desktop/mobile checks, and a Bun-based container runtime. + +The migration goal is to make the project React-based so dashboard UI +components can be reused outside the current app. A partial React island inside +SvelteKit would leave the app split across two component systems, so the target +state is a React runtime and React component library. + +## Target Architecture + +Use Vite React for the browser app and a small Bun HTTP server for production +runtime. This keeps the existing Bun SQLite persistence model and avoids adding +a heavier React framework where the current route/API surface is small. Use +Tailwind CSS v4 and shadcn/ui as the reusable primitive layer for React +components, but do not vendor the full shadcn registry. Add only primitives +that map to the dashboard surface. + +The migrated app will have these boundaries: + +- `src/main.tsx` mounts the React application in the browser. +- `src/App.tsx` owns dashboard loading, refresh timing, document-to-UI mapping, + and non-ready dashboard states. +- `src/server/index.ts` serves the Vite build output in production and exposes + JSON API routes. +- `src/server/routes/dashboard.ts` loads the dashboard runtime and resolves live + datasources unless `DISABLE_LIVE_DATASOURCES=1`. +- `src/server/routes/agent-dashboard.ts` delegates POST requests to the existing + `handleAgentDashboardRequest` function. +- `src/lib/model/**`, `src/lib/server/db/**`, `src/lib/server/dashboard.ts`, + `src/lib/server/datasources/**`, and `src/lib/server/agent-config/**` remain + TypeScript business logic with minimal import-path updates. +- `src/lib/ui/components/*.tsx` contains the reusable React component library. +- `src/lib/ui/stories/*.stories.tsx` contains React Storybook stories. +- `src/lib/components/ui/*.tsx` contains shadcn/ui primitives used by the + dashboard component library. +- `components.json` records the shadcn configuration with Vite, Radix, the Nova + preset, and `$lib` import aliases. + +## Component Migration + +Every current Svelte UI component will be ported to React with typed props: + +- Badge +- Button +- CornerBracketFrame +- DashboardFrame +- DashboardHeader +- DiagonalStripeField +- FooterCell +- FooterStatusCell +- GridFrame +- IconButton +- IconGlyph +- LineChart +- ModuleCard +- Panel +- ProgressMeter +- ScanlineField +- Separator +- ServiceGroupPanel +- ServicePanel +- ServiceRow +- SignalTrace +- Sparkline +- StatusBadge +- StatusStrip +- SystemState +- TelemetryCard +- TelemetryGrid +- TelemetryStrip +- WeatherModule + +The visual design, CSS custom property tokens, accessibility attributes, +data-model identifiers, severity attributes, focusable links, reduced-motion +behavior, and screenshot-tested dashboard layout must stay equivalent to the +current Svelte implementation. + +CSS will keep the existing design tokens in `src/lib/ui/tokens.css`. +`src/app.css` imports Tailwind, shadcn CSS, font assets, and the existing +Dimension Lab token file. shadcn semantic variables must be mapped to the dark +console palette so generated primitives fit the dashboard instead of resetting +the app to a light generic theme. Shared UI types and +`dashboardDocumentToUiDashboard` remain framework-agnostic TypeScript. + +## Runtime And API Behavior + +The React app will fetch `GET /api/dashboard` on page load. When the runtime +state is ready, the response includes the dashboard document and metadata. When +the runtime state is empty, loading, or invalid, the React app renders the same +state shell that the Svelte page currently renders. + +Ready dashboard documents use `metadata.refreshIntervalSeconds` to schedule a +refresh. The React implementation will clear old timers when the dashboard +state changes and on unmount. + +The existing agent configuration endpoint remains available at +`POST /api/agent/dashboard`. The request validation, token authorization, +preview, publish, rollback, JSON patch behavior, and persistence behavior remain +owned by `src/lib/server/agent-config/index.ts`. + +## Build, Storybook, And Deployment + +`package.json` will move from Svelte/SvelteKit dependencies to React tooling: + +- Runtime dependencies include `react`, `react-dom`, `@iconify/react`, + selected shadcn primitive dependencies, Tailwind merge helpers, and existing + non-Svelte libraries that still apply. +- Dev dependencies include `@vitejs/plugin-react`, `@tailwindcss/vite`, + `tailwindcss`, and the shadcn package needed by the generated CSS import. +- Storybook moves from `@storybook/sveltekit` and Svelte CSF to + `@storybook/react-vite`. +- `svelte.config.js`, `src/app.html`, `src/routes/**`, and `.svelte` files are + removed after equivalent React/server files exist. + +The production build still creates `build/index.js` as the Bun server entry so +the current container command remains: + +```sh +DATABASE_URL=file:/data/dimensionlab.sqlite HOST=0.0.0.0 PORT=3000 bun build/index.js +``` + +The `Containerfile` continues to install with Bun, build with Bun, copy the +client/server build output plus `drizzle/`, and run the Bun server. + +## Testing Strategy + +The migration is verified with equivalent or stronger tests: + +- TypeScript check covers React TSX, server modules, and shared model code. +- Current model, validation, database, datasource, and agent-config unit tests + remain in place. +- Svelte SSR component tests become React `react-dom/server` tests. +- Page rendering tests become React app/server response tests. +- Storybook boundary tests are updated to require React story files and continue + preventing Dimension Lab-specific content in reusable presentation stories. +- Playwright desktop/mobile tests continue to run against the production Bun + server and the built React app. +- The full QA gate remains `bun run test:qa`. + +## Completion Criteria + +The migration is complete only when current evidence proves all of these: + +- There are no `.svelte` app, component, route, or story files left. +- `package.json` has no Svelte, SvelteKit, or Svelte Storybook dependencies. +- shadcn is configured for Vite/Radix with `$lib` aliases and only selected + primitives, not the full registry. +- `bun run check` passes. +- `bun run test:unit` passes. +- `bun run build` produces the React client build and Bun server entry. +- `bun run build-storybook` passes with React stories. +- `bun run test:e2e` passes on desktop and mobile. +- The dashboard renders from the same validated dashboard model data. +- `POST /api/agent/dashboard` still exercises the existing agent config logic. +- The production container still runs with `bun build/index.js`. + +## Migration Approach + +The work should be implemented in focused commits on `codex/react-migration`: + +1. Establish React/Vite/Bun server scaffolding and tests while keeping the + current Svelte code available for reference. +2. Port reusable UI components to React and update render tests. +3. Port the dashboard app route and refresh behavior to React. +4. Port Storybook stories and presentation boundary tests to React. +5. Remove SvelteKit runtime files and dependencies. +6. Update build, e2e, README, and container behavior. +7. Run the full QA gate, push the branch, open a ready PR, perform independent + review, fix blockers, and merge only after checks and review pass. 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 fcf2270..e33d3aa 100644 --- a/package.json +++ b/package.json @@ -1,34 +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": "vite --host 0.0.0.0", - "build": "vite build", - "preview": "vite preview --host 0.0.0.0", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run", - "db:generate": "drizzle-kit generate", - "db:check": "drizzle-kit check" - }, - "dependencies": { - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "drizzle-orm": "^0.45.2" + "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": { - "@sveltejs/adapter-node": "^5.5.4", - "@sveltejs/kit": "^2.65.2", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "drizzle-kit": "^0.31.10", - "svelte": "^5.56.3", - "svelte-check": "^4.6.0", - "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.9" + "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 83% rename from src/lib/model/index.ts rename to packages/dashboard-model/src/index.ts index 1a028cc..5ae349a 100644 --- a/src/lib/model/index.ts +++ b/packages/dashboard-model/src/index.ts @@ -1,6 +1,11 @@ export { DASHBOARD_SCHEMA_VERSION, DashboardDocumentSchema, + DatasourceReferenceSchema, + ServiceEntrySchema, + ServiceGroupSchema, + TelemetryCardSchema, + ThresholdSchema, dashboardDocumentJsonSchema, type DashboardDocument, type DashboardModule, 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 95% rename from src/lib/model/schema.ts rename to packages/dashboard-model/src/schema.ts index e0215a4..86e3ab7 100644 --- a/src/lib/model/schema.ts +++ b/packages/dashboard-model/src/schema.ts @@ -27,7 +27,7 @@ const LinkSchema = Type.Object( { additionalProperties: false }, ); -const StaticDatasourceSchema = Type.Object( +export const StaticDatasourceSchema = Type.Object( { type: Type.Literal("static"), label: Type.Optional(Type.String({ minLength: 1 })), @@ -36,7 +36,7 @@ const StaticDatasourceSchema = Type.Object( { additionalProperties: false }, ); -const PlaceholderDatasourceSchema = Type.Object( +export const PlaceholderDatasourceSchema = Type.Object( { type: Type.Literal("placeholder"), reason: Type.String({ minLength: 1 }), @@ -44,7 +44,7 @@ const PlaceholderDatasourceSchema = Type.Object( { additionalProperties: false }, ); -const ExternalDatasourceSchema = Type.Object( +export const ExternalDatasourceSchema = Type.Object( { type: Type.Literal("external"), adapter: Type.Union([ @@ -58,7 +58,7 @@ const ExternalDatasourceSchema = Type.Object( { additionalProperties: false }, ); -const DatasourceReferenceSchema = Type.Union([ +export const DatasourceReferenceSchema = Type.Union([ StaticDatasourceSchema, PlaceholderDatasourceSchema, ExternalDatasourceSchema, @@ -98,13 +98,13 @@ const TextMetricValueSchema = Type.Object( { additionalProperties: false }, ); -const MetricValueSchema = Type.Union([ +export const MetricValueSchema = Type.Union([ PercentMetricValueSchema, NonNegativeMetricValueSchema, TextMetricValueSchema, ]); -const ThresholdSchema = Type.Object( +export const ThresholdSchema = Type.Object( { warning: Type.Optional(Type.Number({ minimum: 0 })), danger: Type.Optional(Type.Number({ minimum: 0 })), 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.css b/src/app.css deleted file mode 100644 index ea0c565..0000000 --- a/src/app.css +++ /dev/null @@ -1,30 +0,0 @@ -:root { - color-scheme: dark; - font-family: - "IBM Plex Mono", "Roboto Mono", "SFMono-Regular", Consolas, monospace; - background: #050505; - color: #f4f4f4; - text-rendering: geometricPrecision; -} - -body { - min-width: 320px; - 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), - #050505; - background-size: 48px 48px; -} - -button, -input, -textarea, -select { - font: inherit; -} - -a { - color: inherit; -} diff --git a/src/app.html b/src/app.html deleted file mode 100644 index adf8bd8..0000000 --- a/src/app.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/src/lib/model/fixtures/dimensionlab.ts b/src/lib/model/fixtures/dimensionlab.ts deleted file mode 100644 index be23b02..0000000 --- a/src/lib/model/fixtures/dimensionlab.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - DASHBOARD_SCHEMA_VERSION, - type DashboardDocument, -} from "../schema"; - -export const dimensionLabDashboardFixture: DashboardDocument = { - schemaVersion: DASHBOARD_SCHEMA_VERSION, - metadata: { - title: "System Overview", - subtitle: "Capacity, noise & top consumers", - description: "Dimension Lab operational dashboard seed data.", - timezone: "Europe/Amsterdam", - refreshIntervalSeconds: 15, - }, - layout: { - density: "dense", - telemetry: [ - "infra-ram", - "gpu-host-ram", - "network-ram", - "user-disk-peak", - "system-disk-peak", - "peak-cpu-busy", - "top-cpu-container", - "top-ram-container", - "gpu-3060-load", - "gpu-3060-vram", - "gpu-3060-temp", - "gpu-3060-fan", - "gpu-3090-load", - "gpu-3090-vram", - "gpu-3090-temp", - "gpu-3090-fan", - ], - serviceGroups: ["essentials", "monitoring", "ai-automation", "systems", "runtime-health"], - statusStrips: ["footer-status"], - modules: ["weather"], - }, - telemetry: [ - metric("infra-ram", "Infra RAM", "percent", 19, "linux-infra", "ok"), - metric("gpu-host-ram", "GPU Host RAM", "percent", 17, "linux", "ok"), - metric("network-ram", "Network RAM", "percent", 5, "network-core", "ok"), - metric("user-disk-peak", "User Disk Peak", "percent", 29, "linux /home", "ok"), - metric("system-disk-peak", "System Disk Peak", "percent", 49, "linux /boot", "ok"), - metric("peak-cpu-busy", "Peak CPU Busy", "percent", 3, "linux-infra", "ok"), - metric("top-cpu-container", "Top CPU Container", "percent", 7.9, "container placeholder", "ok"), - metric("top-ram-container", "Top RAM Container", "bytes", 5.2 * 1024 ** 3, "container placeholder", "danger"), - metric("gpu-3060-load", "3060 GPU Load", "percent", 0, "RTX 3060", "ok"), - metric("gpu-3060-vram", "3060 VRAM", "percent", 0, "RTX 3060", "ok"), - metric("gpu-3060-temp", "3060 Temp", "temperature", 54, "RTX 3060", "ok"), - metric("gpu-3060-fan", "3060 Fan Spin", "percent", 0, "RTX 3060", "ok"), - metric("gpu-3090-load", "3090 GPU Load", "percent", 0, "RTX 3090", "ok"), - metric("gpu-3090-vram", "3090 VRAM", "percent", 74, "RTX 3090", "warning"), - metric("gpu-3090-temp", "3090 Temp", "temperature", 50, "RTX 3090", "ok"), - metric("gpu-3090-fan", "3090 Fan Spin", "percent", 0, "RTX 3090", "ok"), - ], - serviceGroups: [ - group("essentials", "Essentials", [ - service("vaultwarden", "Vaultwarden", "Password manager", "simple-icons:vaultwarden", "https://vault.dimensionlab.net"), - service("forgejo", "Forgejo", "Git repositories", "simple-icons:forgejo", "https://git.dimensionlab.net"), - service("wiki", "Wiki", "Internal documentation", "simple-icons:wikidotjs", "https://wiki.dimensionlab.net"), - service("aws-start", "AWS Start", "AWS access portal", "simple-icons:amazonaws", "https://dimensionlab.awsapps.com/start"), - ]), - group("monitoring", "Monitoring", [ - service("grafana", "Grafana", "Capacity and noise dashboard", "simple-icons:grafana", "https://grafana.dimensionlab.net"), - service("uptime-kuma", "Uptime Kuma", "Service uptime checks", "simple-icons:uptimekuma", "https://uptime.dimensionlab.net"), - service("prometheus", "Prometheus", "Metrics database", "simple-icons:prometheus", "https://prometheus.dimensionlab.net"), - service("backrest", "Backrest", "Restic backup manager", "mdi:backup-restore", "https://backups.dimensionlab.net"), - ]), - group("ai-automation", "AI & Automation", [ - service("n8n", "n8n", "Workflow automation", "simple-icons:n8n", "https://workflows.dimensionlab.net"), - service("open-webui", "Open WebUI", "Chat and model interface", "simple-icons:openwebui", "https://webui.dimensionlab.net"), - service("comfyui", "ComfyUI", "Image generation workflows", "mdi:image-edit-outline", "https://comfy.dimensionlab.net"), - service("models", "Models", "Local model management", "mdi:brain", "https://models.dimensionlab.net"), - ]), - group("systems", "Systems", [ - service("adminer", "Adminer", "PostgreSQL database browser", "simple-icons:adminer", "https://db.dimensionlab.net"), - service("assistant", "Assistant", "Personal AI agent gateway", "mdi:robot-outline", "https://assistant.dimensionlab.net"), - service("suna", "Suna", "AI command center", "mdi:account-hard-hat-outline", "https://suna.dimensionlab.net"), - service("cockpit-infra", "Cockpit Infra", "linux-infra server console", "simple-icons:cockpit", "https://infra-cockpit.dimensionlab.net"), - ]), - group("runtime-health", "Runtime Health", [ - service("forgejo-ssh-relay", "Forgejo SSH Relay", "Public Git SSH relay", "simple-icons:forgejo", "https://uptime.dimensionlab.net/status/dimensionlab"), - service("postgresql", "PostgreSQL", "Shared application database", "simple-icons:postgresql"), - service("ollama-api", "Ollama API", "Local model API", "simple-icons:ollama"), - service("node-exporter", "Node Exporter", "Host metrics exporter", "simple-icons:prometheus"), - ]), - ], - statusStrips: [ - { - id: "footer-status", - items: [ - { id: "system-status", label: "System Status", value: "All systems operational", severity: "ok" }, - { id: "last-sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" }, - { id: "uptime", label: "Uptime", value: "28d 14h 32m", severity: "ok" }, - { id: "load-avg", label: "Load Avg", value: "0.47 0.53 0.59", severity: "neutral" }, - ], - }, - ], - modules: [ - { - id: "weather", - kind: "weather", - title: "Amsterdam", - value: "28.3 C", - detail: "Clear", - icon: "mdi:weather-sunny", - severity: "ok", - datasource: { type: "placeholder", reason: "weather adapter pending" }, - }, - ], -}; - -type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number"; -type Severity = "neutral" | "ok" | "warning" | "danger" | "stale" | "unavailable"; - -function metric( - id: string, - label: string, - kind: NumericValueKind, - value: number, - detail: string, - severity: Severity, -) { - return { - id, - label, - value: { kind, value }, - detail, - severity, - datasource: { type: "placeholder" as const, reason: "live datasource pending" }, - sparkline: [10, 18, 15, 28, 24, 32], - }; -} - -function service( - id: string, - label: string, - description: string, - icon: string, - href?: string, -) { - const entry = { - id, - label, - description, - icon, - severity: "ok" as const, - detail: "ready", - datasource: { type: "placeholder" as const, reason: "service health adapter pending" }, - }; - - return href ? { ...entry, link: { href, external: true } } : entry; -} - -function group(id: string, title: string, services: ReturnType[]) { - return { - id, - title, - layout: "list" as const, - services, - }; -} 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/server/dashboard.test.ts b/src/lib/server/dashboard.test.ts deleted file mode 100644 index 91a5ffb..0000000 --- a/src/lib/server/dashboard.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { rmSync } from "node:fs"; -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 { createDashboardStore, type DashboardStore } from "./db/dashboard-store"; -import { loadDashboardRuntime } from "./dashboard"; - -const stores: DashboardStore[] = []; -const tempRoots: string[] = []; - -afterEach(() => { - stores.splice(0).forEach((store) => store.close()); - tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true })); -}); - -describe("dashboard runtime loader", () => { - test("seeds and loads the active dashboard from sqlite", async () => { - const store = await createTestStore(); - - const runtime = loadDashboardRuntime(store); - - expect(runtime.title).toBe(dimensionLabDashboardFixture.metadata.title); - expect(runtime.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle); - expect(runtime.status).toBe("building"); - expect(runtime.schemaVersion).toBe(dimensionLabDashboardFixture.schemaVersion); - expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId); - expect(store.listRevisions()).toHaveLength(1); - }); -}); - -async function createTestStore() { - const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-runtime-")); - tempRoots.push(root); - const store = createDashboardStore({ - databaseUrl: `file:${join(root, "dashboard.sqlite")}`, - }); - stores.push(store); - - return store; -} diff --git a/src/lib/server/dashboard.ts b/src/lib/server/dashboard.ts deleted file mode 100644 index 244e97b..0000000 --- a/src/lib/server/dashboard.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; -import { - createDashboardStore, - type DashboardStore, -} from "$lib/server/db/dashboard-store"; - -export type PlaceholderStatus = "ready" | "building"; - -export interface PlaceholderDashboard { - title: string; - subtitle: string; - status: PlaceholderStatus; - message: string; - schemaVersion?: string; - currentRevisionId?: string; -} - -export function loadPlaceholderDashboard(): PlaceholderDashboard { - return { - title: "System Overview", - subtitle: "Dashboard runtime scaffold", - status: "building", - message: - "SvelteKit is running. Later issues will replace this placeholder with the validated dashboard model.", - }; -} - -export function loadDashboardRuntime(store?: DashboardStore): PlaceholderDashboard { - const dashboardStore = store || createDashboardStore(); - - try { - const seeded = dashboardStore.seedDashboardIfEmpty(dimensionLabDashboardFixture, { - actor: "dimensionlab-seed", - message: "load initial dashboard seed", - }); - const active = dashboardStore.getActiveDashboard(); - const document = active?.document || seeded.document; - const currentRevisionId = active?.currentRevisionId || seeded.id; - - return { - title: document.metadata.title, - subtitle: document.metadata.subtitle || "", - status: "building", - message: - "Active dashboard document loaded from SQLite. Later issues will replace this placeholder with model-driven rendering.", - schemaVersion: document.schemaVersion, - currentRevisionId, - }; - } finally { - if (!store) dashboardStore.close(); - } -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte deleted file mode 100644 index 801be90..0000000 --- a/src/routes/+layout.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - -{@render children()} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts deleted file mode 100644 index 114bfe5..0000000 --- a/src/routes/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { loadDashboardRuntime } from "$lib/server/dashboard"; - -export function load() { - return { - dashboard: loadDashboardRuntime(), - }; -} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 68a3c90..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - {data.dashboard.title} | Dimension Lab - - - -
-
-

{data.dashboard.subtitle}

-

{data.dashboard.title}

-

{data.dashboard.message}

-
- Runtime - {data.dashboard.status} -
-
-
- - diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index bc8b8d4..0000000 --- a/svelte.config.js +++ /dev/null @@ -1,12 +0,0 @@ -import adapter from "@sveltejs/adapter-node"; -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -/** @type {import("@sveltejs/kit").Config} */ -const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter(), - }, -}; - -export default config; diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..dcde3db --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "6.0", + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "types": ["node", "bun-types", "react", "react-dom"] + } +} diff --git a/tsconfig.json b/tsconfig.json index c08a57f..8fe9b00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,8 @@ { - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler", - "types": ["node", "bun-types"] - } + "files": [], + "references": [ + { "path": "./packages/dashboard-model" }, + { "path": "./apps/web" }, + { "path": "./packages/ui" } + ] } diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..e1b2e7c --- /dev/null +++ b/turbo.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [ + "bun.lock", + "package.json", + "tsconfig.base.json", + "tsconfig.json" + ], + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**", "build/**"], + "env": ["NODE_ENV", "VITE_*"] + }, + "check": { + "dependsOn": ["^build"], + "outputs": [] + }, + "test:unit": { + "dependsOn": ["^build"], + "outputs": [], + "env": [ + "AGENT_CONFIG_TOKEN", + "DASHBOARD_MIGRATIONS_DIR", + "DATABASE_URL", + "DISABLE_LIVE_DATASOURCES", + "PROMETHEUS_BASE_URL" + ] + }, + "build-storybook": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["storybook-static/**"], + "env": ["NODE_ENV", "STORYBOOK_*", "VITE_*"] + }, + "test:e2e": { + "dependsOn": [ + "build", + "^build", + "@dimensionlab/ui#build-storybook" + ], + "outputs": ["test-results/**", "playwright-report/**"], + "env": [ + "AGENT_CONFIG_TOKEN", + "CI", + "DASHBOARD_MIGRATIONS_DIR", + "DATABASE_URL", + "DISABLE_LIVE_DATASOURCES", + "PLAYWRIGHT_DATABASE_URL", + "PLAYWRIGHT_PORT", + "PLAYWRIGHT_STORYBOOK_PORT", + "PROMETHEUS_BASE_URL", + "STORYBOOK_STATIC_PORT" + ] + }, + "db:generate": { + "cache": false + }, + "db:check": { + "outputs": [], + "env": ["DATABASE_URL"] + }, + "dev": { + "dependsOn": ["^build"], + "cache": false, + "persistent": true, + "env": [ + "AGENT_CONFIG_TOKEN", + "DASHBOARD_DEV_API_HOST", + "DASHBOARD_DEV_API_PORT", + "DASHBOARD_DEV_API_TARGET", + "DASHBOARD_MIGRATIONS_DIR", + "DATABASE_URL", + "HOST", + "PORT", + "PROMETHEUS_BASE_URL" + ] + }, + "preview": { + "cache": false, + "persistent": true, + "env": [ + "AGENT_CONFIG_TOKEN", + "DASHBOARD_MIGRATIONS_DIR", + "DATABASE_URL", + "HOST", + "PORT", + "PROMETHEUS_BASE_URL" + ] + }, + "storybook": { + "cache": false, + "persistent": true, + "env": ["HOST", "PORT", "STORYBOOK_*", "VITE_*"] + } + } +} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 80864b9..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { sveltekit } from "@sveltejs/kit/vite"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [sveltekit()], -});