Compare commits
108 commits
codex/issu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2c998b894 | |||
|
|
799299efca | ||
|
|
6405254e42 | ||
| 8b0ef39cdf | |||
|
|
d00a7b3b80 | ||
| da9a5a942d | |||
|
|
16c69a1065 | ||
| b087e65978 | |||
|
|
35cb1574b4 | ||
|
|
d61d296ea1 | ||
|
|
006c7e041f | ||
|
|
a010520a94 | ||
|
|
7e4d13a85c | ||
|
|
96fe4d26c7 | ||
| 29077923e8 | |||
|
|
3188d8b905 | ||
| 216545b838 | |||
|
|
41fb964ebb | ||
| a66f1fdd57 | |||
|
|
a7d0a4c3b2 | ||
|
|
de87464ef1 | ||
| 63b008095f | |||
|
|
0d27920a78 | ||
|
|
328f6e00bf | ||
| b6de67f081 | |||
|
|
a071df3194 | ||
| 30bff9da37 | |||
|
|
bac809952a | ||
|
|
88c448258d | ||
|
|
4ce42fb52b | ||
| 243d71686d | |||
|
|
5e66367687 | ||
| 9aa30b07db | |||
|
|
bc1f64d566 | ||
| cda40401ca | |||
|
|
fd0559a143 | ||
| 83c2c0f4a4 | |||
|
|
74d9aaf2b4 | ||
| b0b09eb8ad | |||
|
|
631e789546 | ||
| 9acaa18e6d | |||
|
|
2a1d94195e | ||
|
|
2963c5dfde | ||
| 73a15a2b8b | |||
|
|
23c8f0964c | ||
|
|
831b1b6640 | ||
|
|
7a3a5b8df7 | ||
| 021f2cf20e | |||
|
|
24e7e8eda0 | ||
| 3ff41e771b | |||
|
|
5dccd70e0b | ||
|
|
2535dccff2 | ||
| 7b96a7cb76 | |||
|
|
ef43aac6df | ||
| 243d05cef7 | |||
|
|
2d779165cd | ||
|
|
a77826eab9 | ||
| 9240ae4925 | |||
|
|
74e70cf714 | ||
|
|
3e46a2c0cc | ||
| 220fb55a47 | |||
|
|
fe8d1e5642 | ||
|
|
32ad2cebe4 | ||
| 8927f2ab8a | |||
|
|
9311b81102 | ||
|
|
7a6ad8ab0e | ||
| 22f50d64f9 | |||
|
|
c8183bcc47 | ||
|
|
ae6eabed6f | ||
| 4fc89c4d3f | |||
|
|
72309c0c09 | ||
|
|
e93f4aabb7 | ||
|
|
b4e626a868 | ||
|
|
2664804e91 | ||
|
|
87261b5a3f | ||
|
|
4eadd21f71 | ||
|
|
786a283f31 | ||
| 6b476bc91a | |||
|
|
8bb5052dd5 | ||
|
|
0c75220adc | ||
| 739d627ae3 | |||
|
|
9c948bd746 | ||
| c84de9aaa9 | |||
|
|
264c3a7edb | ||
|
|
0b2deb8405 | ||
|
|
b923ac8947 | ||
|
|
cdb269e505 | ||
| bbc81cea0d | |||
|
|
b8e39348e8 | ||
|
|
b1d196e4ca | ||
|
|
72d56cb3c9 | ||
|
|
76fe468df5 | ||
|
|
92a8990e7a | ||
|
|
73abcc1b69 | ||
|
|
a71d66b1b8 | ||
|
|
f6d6d39a7b | ||
|
|
abfb885851 | ||
|
|
ef9715c5a6 | ||
| e0a34f7f2f | |||
|
|
83efa5b051 | ||
| eeac37e58e | |||
|
|
b69de71029 | ||
|
|
9b7f20c486 | ||
| 8af0fce6f1 | |||
|
|
ff4e6c6d36 | ||
| bf6e7c0cc9 | |||
|
|
ba904ef4f9 | ||
| 769d624799 |
163 changed files with 11303 additions and 4396 deletions
22
.containerignore
Normal file
22
.containerignore
Normal file
|
|
@ -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
|
||||||
85
.forgejo/workflows/dimensionlab-website.yml
Normal file
85
.forgejo/workflows/dimensionlab-website.yml
Normal file
|
|
@ -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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,8 +1,12 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
out/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
.vite/
|
.vite/
|
||||||
|
.turbo/
|
||||||
|
apps/*/.turbo/
|
||||||
|
packages/*/.turbo/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
@ -10,6 +14,8 @@ dist/
|
||||||
|
|
||||||
data/*.sqlite
|
data/*.sqlite
|
||||||
data/*.sqlite-*
|
data/*.sqlite-*
|
||||||
|
apps/*/data/*.sqlite
|
||||||
|
apps/*/data/*.sqlite-*
|
||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
|
|
||||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule "packages/ui"]
|
||||||
|
path = packages/ui
|
||||||
|
url = ssh://git@git.dimensionlab.net/vince/dimensionlab-ui.git
|
||||||
|
branch = main
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import type { StorybookConfig } from "@storybook/sveltekit";
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
stories: ["../src/**/*.stories.@(js|ts|svelte)"],
|
|
||||||
staticDirs: ["../static"],
|
|
||||||
addons: [
|
|
||||||
"@storybook/addon-svelte-csf",
|
|
||||||
"@storybook/addon-a11y",
|
|
||||||
"@storybook/addon-vitest",
|
|
||||||
],
|
|
||||||
framework: {
|
|
||||||
name: "@storybook/sveltekit",
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
autodocs: "tag",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import "../src/app.css";
|
|
||||||
import type { Preview } from "@storybook/sveltekit";
|
|
||||||
import { setupWorker } from "msw/browser";
|
|
||||||
import { externalApiHandlers } from "../src/lib/testing/external-api-mocks";
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const worker = setupWorker(...externalApiHandlers);
|
|
||||||
void worker.start({
|
|
||||||
onUnhandledRequest: "bypass",
|
|
||||||
serviceWorker: {
|
|
||||||
url: "/mockServiceWorker.js",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
backgrounds: {
|
|
||||||
default: "canvas",
|
|
||||||
values: [
|
|
||||||
{ name: "canvas", value: "#020302" },
|
|
||||||
{ name: "raised", value: "#0b0d0c" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/i,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layout: "fullscreen",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default preview;
|
|
||||||
143
README.md
143
README.md
|
|
@ -1,15 +1,30 @@
|
||||||
# Dimension Lab Website
|
# 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
|
This project is not a Homepage customization and does not depend on Homepage
|
||||||
runtime, frontend code, or configuration. The dashboard will be model-driven:
|
runtime, frontend code, or configuration. The dashboard will be model-driven:
|
||||||
the reusable renderer stays content-free, while environment-specific data lives
|
the reusable UI package stays content-free, the reusable dashboard model
|
||||||
in validated dashboard model state.
|
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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
git submodule update --init --recursive
|
||||||
bun install
|
bun install
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
@ -18,18 +33,19 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `bun run dev`: start the local development server.
|
- `bun run dev`: start the web app dev runtime through Turbo.
|
||||||
- `bun run check`: run Svelte and TypeScript checks.
|
- `bun run check`: run TypeScript checks in all workspaces.
|
||||||
- `bun run test`: run Vitest.
|
- `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: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:e2e`: build and run Playwright browser smoke and QA checks.
|
||||||
- `bun run test:qa`: run the MVP release gate.
|
- `bun run test:qa`: run the release gate through Turbo across check, unit,
|
||||||
- `bun run build`: build the production app.
|
build, Storybook, and e2e tasks.
|
||||||
- `bun run preview`: preview the production build.
|
- `bun run build`: build the UI package, production website, and Bun server.
|
||||||
- `bun run storybook`: start the component explorer on port 6006.
|
- `bun run preview`: preview the production web build.
|
||||||
- `bun run build-storybook`: build the static Storybook review artifact.
|
- `bun run storybook`: start the UI package component explorer on port 6006.
|
||||||
- `bun run db:generate`: generate Drizzle migrations from the server schema.
|
- `bun run build-storybook`: build the UI package static Storybook artifact.
|
||||||
- `bun run db:check`: validate migration consistency.
|
- `bun run db:generate`: generate web app Drizzle migrations.
|
||||||
|
- `bun run db:check`: validate web app migration consistency.
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
|
|
@ -40,14 +56,14 @@ URL is:
|
||||||
DATABASE_URL=file:./data/dimensionlab.sqlite
|
DATABASE_URL=file:./data/dimensionlab.sqlite
|
||||||
```
|
```
|
||||||
|
|
||||||
SQLite files under `data/` are ignored. Drizzle schema lives in
|
SQLite files under `data/` and `apps/*/data/` are ignored. Drizzle schema lives
|
||||||
`src/lib/server/db/schema.ts`; tracked migrations live in `drizzle/`. Runtime
|
in `apps/web/src/lib/server/db/schema.ts`; tracked migrations live in
|
||||||
startup applies the checked-in dashboard migrations before reads or writes. If
|
`apps/web/drizzle/`. Runtime startup applies the checked-in dashboard migrations
|
||||||
the app is launched from outside the repo tree, set `DASHBOARD_MIGRATIONS_DIR`
|
before reads or writes. If the app is launched from outside the web app tree,
|
||||||
to the tracked migrations directory. The current driver is `bun:sqlite`, which
|
set `DASHBOARD_MIGRATIONS_DIR` to the tracked migrations directory. The current
|
||||||
keeps this repo installable in the Bun workflow. The store boundary is isolated
|
driver is `bun:sqlite`, which keeps this repo installable in the Bun workflow.
|
||||||
so a later Postgres driver can replace the SQLite connection without changing
|
The store boundary is isolated so a later Postgres driver can replace the
|
||||||
the dashboard model or renderer.
|
SQLite connection without changing the dashboard model or renderer.
|
||||||
Stored dashboard documents pass through a version migration boundary before
|
Stored dashboard documents pass through a version migration boundary before
|
||||||
reads or writes; the MVP supports `dashboard.v1` and fails unsupported versions
|
reads or writes; the MVP supports `dashboard.v1` and fails unsupported versions
|
||||||
with an explicit migration error.
|
with an explicit migration error.
|
||||||
|
|
@ -55,23 +71,26 @@ with an explicit migration error.
|
||||||
## Seed Data
|
## Seed Data
|
||||||
|
|
||||||
The initial Dimension Lab dashboard lives in
|
The initial Dimension Lab dashboard lives in
|
||||||
`src/lib/model/fixtures/dimensionlab.ts` as validated model data. It includes
|
`apps/web/src/lib/dashboard-seed/dimensionlab.ts` as validated model data. It
|
||||||
the first-screen telemetry, service groups, status strip, weather module,
|
includes the first-screen telemetry, service groups, status strip, weather
|
||||||
Iconify icon identifiers, links, and datasource references. Values that are not
|
module, Iconify icon identifiers, links, and datasource references. Values that
|
||||||
live yet are labeled as fallback values in the data so later datasource adapters
|
are not live yet are labeled as fallback values in the data so later datasource
|
||||||
can replace them without changing presentation components.
|
adapters can replace them without changing presentation components.
|
||||||
|
|
||||||
## Runtime Shape
|
## Runtime Shape
|
||||||
|
|
||||||
The SvelteKit build uses the Node adapter, and the current persistence runtime
|
The browser app in `apps/web` is built with Vite and React. Local development
|
||||||
is Bun because the MVP SQLite driver is `bun:sqlite`. Later issues add the
|
starts Vite for HMR and a loopback Bun API server for `/api/*` routes.
|
||||||
seed data expansion and rendering pipeline.
|
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
|
||||||
|
|
||||||
Storybook covers the reusable UI components with generic fixtures only. Stories
|
Storybook lives with `packages/ui` and covers the reusable UI components with
|
||||||
must not import environment-specific dashboard content; the presentation layer
|
generic fixtures only. Stories must not import environment-specific dashboard
|
||||||
accepts labels, values, icons, status, and links through typed props.
|
content; the presentation layer accepts labels, values, icons, status, and links
|
||||||
|
through typed props.
|
||||||
|
|
||||||
## MVP QA Gate
|
## MVP QA Gate
|
||||||
|
|
||||||
|
|
@ -87,10 +106,12 @@ Run the CI-ready release gate with:
|
||||||
bun run test:qa
|
bun run test:qa
|
||||||
```
|
```
|
||||||
|
|
||||||
The gate runs Svelte/TypeScript checks, Vitest coverage for model,
|
The gate runs TypeScript checks, Vitest coverage for model,
|
||||||
persistence, renderer, datasource mocks, and presentation boundaries, the
|
persistence, renderer, datasource mocks, and presentation boundaries, the
|
||||||
production build, the static Storybook build, and Playwright desktop/mobile
|
production build, the static Storybook build, and Playwright desktop/mobile
|
||||||
smoke checks against the built adapter output. Playwright also performs
|
smoke checks against the built adapter output. Turbo owns the release task
|
||||||
|
graph; app package scripts stay as leaf commands and do not re-run the QA
|
||||||
|
pipeline internally. Playwright also performs
|
||||||
baseline screenshot checks, keyboard navigation checks, reduced-motion checks,
|
baseline screenshot checks, keyboard navigation checks, reduced-motion checks,
|
||||||
landmark checks, and axe accessibility checks against the real model-driven
|
landmark checks, and axe accessibility checks against the real model-driven
|
||||||
route.
|
route.
|
||||||
|
|
@ -104,15 +125,63 @@ belong in validated model data, not reusable components.
|
||||||
|
|
||||||
## Deployment Notes
|
## Deployment Notes
|
||||||
|
|
||||||
The production build uses the SvelteKit Node adapter, but the current SQLite
|
The production build emits Vite client assets under `apps/web/dist/` and a Bun
|
||||||
driver depends on Bun. A minimal deployment flow is:
|
server entry at `apps/web/build/index.js`. A minimal deployment flow is:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
git submodule update --init --recursive
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run build
|
bun run build
|
||||||
|
cd apps/web
|
||||||
DATABASE_URL=file:/data/dimensionlab.sqlite HOST=0.0.0.0 PORT=3000 bun build/index.js
|
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
|
Mount `/data` or set `DATABASE_URL` to another persistent SQLite path. If the
|
||||||
process starts outside the repository root, set `DASHBOARD_MIGRATIONS_DIR` to
|
process starts outside the repository root, set `DASHBOARD_MIGRATIONS_DIR` to
|
||||||
the checked-in `drizzle/` directory so startup migrations can run.
|
the checked-in `apps/web/drizzle/` directory so startup migrations can run.
|
||||||
|
|
||||||
|
### Internal Container
|
||||||
|
|
||||||
|
The checked-in `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.
|
||||||
|
|
|
||||||
39
apps/web/Containerfile
Normal file
39
apps/web/Containerfile
Normal file
|
|
@ -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"]
|
||||||
22
apps/web/index.html
Normal file
22
apps/web/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Dimension Lab dashboard runtime" />
|
||||||
|
<title>Dimension Lab</title>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem("dashboard-ui-theme");
|
||||||
|
document.documentElement.dataset.uiTheme =
|
||||||
|
theme === "light" ? "light" : "dark";
|
||||||
|
} catch {
|
||||||
|
document.documentElement.dataset.uiTheme = "dark";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
apps/web/package.json
Normal file
51
apps/web/package.json
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const port = Number(process.env.PLAYWRIGHT_PORT || 4173);
|
const port = Number(process.env.PLAYWRIGHT_PORT || 4173);
|
||||||
const baseURL = `http://127.0.0.1:${port}`;
|
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 =
|
const databaseUrl =
|
||||||
process.env.PLAYWRIGHT_DATABASE_URL ||
|
process.env.PLAYWRIGHT_DATABASE_URL ||
|
||||||
`file:./data/playwright-${process.pid}-${Date.now()}.sqlite`;
|
`file:./data/playwright-${process.pid}-${Date.now()}.sqlite`;
|
||||||
|
|
@ -17,12 +19,20 @@ export default defineConfig({
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: [
|
||||||
command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`,
|
{
|
||||||
url: baseURL,
|
command: `DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`,
|
||||||
reuseExistingServer: false,
|
url: baseURL,
|
||||||
timeout: 120_000,
|
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: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium-desktop",
|
name: "chromium-desktop",
|
||||||
238
apps/web/src/App.test.tsx
Normal file
238
apps/web/src/App.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "loading",
|
||||||
|
title: "Loading Dashboard",
|
||||||
|
subtitle: "Fetching active model",
|
||||||
|
message: "Waiting for the active dashboard document.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Loading Dashboard");
|
||||||
|
expect(html).toContain("Fetching active model");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders an accessible theme toggle with the active theme", () => {
|
||||||
|
const html = renderToString(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "loading",
|
||||||
|
title: "Loading Dashboard",
|
||||||
|
subtitle: "Fetching active model",
|
||||||
|
message: "Waiting for the active dashboard document.",
|
||||||
|
}}
|
||||||
|
theme="dark"
|
||||||
|
onThemeChange={() => 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(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "ready",
|
||||||
|
document: genericDashboardFixture,
|
||||||
|
schemaVersion: "dashboard.v1",
|
||||||
|
currentRevisionId: "revision-1234567890",
|
||||||
|
}}
|
||||||
|
hydratingItemIds={new Set([
|
||||||
|
"telemetry:service-uptime",
|
||||||
|
"service:core-services:identity",
|
||||||
|
"module:ambient",
|
||||||
|
"status:runtime:status",
|
||||||
|
])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
875
apps/web/src/App.tsx
Normal file
875
apps/web/src/App.tsx
Normal file
|
|
@ -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<typeof createDashboardTileSnapshotStore>;
|
||||||
|
|
||||||
|
export function AppStateView({
|
||||||
|
dashboard,
|
||||||
|
onThemeChange,
|
||||||
|
theme,
|
||||||
|
hydratingItemIds,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardRuntimeState;
|
||||||
|
hydratingItemIds?: ReadonlySet<string>;
|
||||||
|
onThemeChange?: (theme: UiTheme) => void;
|
||||||
|
theme?: UiTheme;
|
||||||
|
}) {
|
||||||
|
const themeToggle =
|
||||||
|
theme && onThemeChange ? (
|
||||||
|
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (dashboard.state === "ready") {
|
||||||
|
const uiDashboard = markHydratingItems(
|
||||||
|
dashboardDocumentToUiDashboard(dashboard.document),
|
||||||
|
hydratingItemIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardFrame
|
||||||
|
dashboard={uiDashboard}
|
||||||
|
actions={themeToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = `${dashboard.subtitle}: ${dashboard.message}`;
|
||||||
|
const errors = dashboard.state === "invalid" ? dashboard.errors : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="state-shell" data-dashboard-state={dashboard.state}>
|
||||||
|
{themeToggle ? (
|
||||||
|
<div className="state-shell__actions">{themeToggle}</div>
|
||||||
|
) : null}
|
||||||
|
<SystemState
|
||||||
|
title={dashboard.title}
|
||||||
|
detail={detail}
|
||||||
|
severity={stateSeverity(dashboard.state)}
|
||||||
|
icon={stateIcon(dashboard.state)}
|
||||||
|
/>
|
||||||
|
{errors.length ? (
|
||||||
|
<ul aria-label="Validation errors">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<li key={error}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DashboardRuntimeState | null>(null);
|
||||||
|
const [hydratingItemIds, setHydratingItemIds] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||||
|
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<HTMLMetaElement>(
|
||||||
|
'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<Array<{ result: DashboardTileHydrationResult; tile: DashboardTileReference }>> {
|
||||||
|
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<DashboardTileHydrationResult> {
|
||||||
|
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 ? (
|
||||||
|
<AppStateView
|
||||||
|
dashboard={dashboard}
|
||||||
|
hydratingItemIds={hydratingItemIds}
|
||||||
|
theme={theme}
|
||||||
|
onThemeChange={setTheme}
|
||||||
|
/>
|
||||||
|
) : 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<void> {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
await new Promise<void>((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<string>;
|
||||||
|
} {
|
||||||
|
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<DashboardTileResponse, { state: "ready" }>,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
restoredItemIds: new Set([
|
||||||
|
...current.restoredItemIds,
|
||||||
|
dashboardTileKey(snapshot.response.tile),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
dashboard,
|
||||||
|
restoredItemIds: new Set<string>(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markHydratingItems(
|
||||||
|
dashboard: UiDashboardPreview,
|
||||||
|
hydratingItemIds?: ReadonlySet<string>,
|
||||||
|
): 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<DashboardTileResponse, { state: "ready" }>,
|
||||||
|
): 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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
170
apps/web/src/app.css
Normal file
170
apps/web/src/app.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
633
apps/web/src/lib/client/dashboard-refresh.test.ts
Normal file
633
apps/web/src/lib/client/dashboard-refresh.test.ts
Normal file
|
|
@ -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<number, () => 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<void>((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<void>((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<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
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<string>) => 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<string>);
|
||||||
|
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<string, string>();
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
886
apps/web/src/lib/client/dashboard-refresh.ts
Normal file
886
apps/web/src/lib/client/dashboard-refresh.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
export type DashboardTileSnapshotItem = {
|
||||||
|
detail?: string;
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
severity?: string;
|
||||||
|
value?: SnapshotMetricValue | string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
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<TItem> {
|
||||||
|
concurrency: number;
|
||||||
|
hydrate: (item: TItem) => Promise<void> | void;
|
||||||
|
items: TItem[];
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDashboardHydrationQueue<TItem>(
|
||||||
|
options: DashboardHydrationQueueOptions<TItem>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<TItem> {
|
||||||
|
batchSize?: number;
|
||||||
|
collectVisibleModelIds: () => Promise<ReadonlySet<string>>;
|
||||||
|
concurrency: number;
|
||||||
|
getModelId: (item: TItem) => string;
|
||||||
|
hydrate: (item: TItem) => Promise<void> | void;
|
||||||
|
hydrateBatch?: (items: TItem[]) => Promise<void> | void;
|
||||||
|
items: TItem[];
|
||||||
|
onAllItemsSettled?: () => void;
|
||||||
|
onVisibleItemsSettled?: () => void;
|
||||||
|
signal: AbortSignal;
|
||||||
|
waitForIdle: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||||
|
options: DashboardViewportHydrationQueueOptions<TItem>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<TItem>(
|
||||||
|
options: DashboardViewportHydrationQueueOptions<TItem>,
|
||||||
|
items: TItem[],
|
||||||
|
): Promise<void> {
|
||||||
|
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<TItem>(options: {
|
||||||
|
batchSize: number;
|
||||||
|
hydrateBatch: (items: TItem[]) => Promise<void> | void;
|
||||||
|
items: TItem[];
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<TItem>(options: {
|
||||||
|
getModelId: (item: TItem) => string;
|
||||||
|
items: TItem[];
|
||||||
|
visibleModelIds: ReadonlySet<string>;
|
||||||
|
}): {
|
||||||
|
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<DashboardModelElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof setTimeout>;
|
||||||
|
|
||||||
|
export interface DashboardVisibleModelIdCollectorOptions {
|
||||||
|
clearTimeout?: (handle: DashboardTimerHandle) => void;
|
||||||
|
createObserver?: DashboardIntersectionObserverFactory;
|
||||||
|
documentTarget: DashboardViewportElementSource;
|
||||||
|
modelIds: Iterable<string>;
|
||||||
|
setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle;
|
||||||
|
signal: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectVisibleDashboardModelIds(
|
||||||
|
options: DashboardVisibleModelIdCollectorOptions,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
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<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<string>) => 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<string, { attempts: number; nextAttemptAt: number }>();
|
||||||
|
|
||||||
|
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<DashboardTileSnapshotResponse, { state: "ready" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DashboardTileSnapshotPayload>;
|
||||||
|
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<DashboardTileSnapshotResponse, { state: "ready" }>;
|
||||||
|
},
|
||||||
|
): 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<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import type {
|
||||||
DatasourceReference,
|
DatasourceReference,
|
||||||
ServiceEntry,
|
ServiceEntry,
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "../schema";
|
} from "@dimensionlab/dashboard-model";
|
||||||
|
|
||||||
describe("Dimension Lab dashboard seed", () => {
|
describe("Dimension Lab dashboard seed", () => {
|
||||||
test("defines the primary first-screen sections as model data", () => {
|
test("defines the primary first-screen sections as model data", () => {
|
||||||
|
|
@ -128,7 +128,6 @@ const verifiedSeedIconIds = new Set([
|
||||||
"mdi:pulse",
|
"mdi:pulse",
|
||||||
"mdi:robot-outline",
|
"mdi:robot-outline",
|
||||||
"mdi:router-network",
|
"mdi:router-network",
|
||||||
"mdi:text-box-search",
|
|
||||||
"mdi:thermometer",
|
"mdi:thermometer",
|
||||||
"mdi:web",
|
"mdi:web",
|
||||||
"mdi:weather-sunny",
|
"mdi:weather-sunny",
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
type ServiceGroup,
|
type ServiceGroup,
|
||||||
type Severity,
|
type Severity,
|
||||||
type TelemetryCard,
|
type TelemetryCard,
|
||||||
} from "../schema";
|
} from "@dimensionlab/dashboard-model";
|
||||||
|
|
||||||
type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number";
|
type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number";
|
||||||
|
|
||||||
|
|
@ -431,14 +431,6 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
|
||||||
href: "https://models.dimensionlab.net",
|
href: "https://models.dimensionlab.net",
|
||||||
datasource: uptimeMonitor(7),
|
datasource: uptimeMonitor(7),
|
||||||
}),
|
}),
|
||||||
service({
|
|
||||||
id: "prompt-registry",
|
|
||||||
label: "Prompt Registry",
|
|
||||||
description: "Shared prompts, traces, evals",
|
|
||||||
icon: "mdi:text-box-search",
|
|
||||||
href: "https://prompts.dimensionlab.net",
|
|
||||||
datasource: uptimeMonitor(20),
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
group("systems", "Systems", [
|
group("systems", "Systems", [
|
||||||
service({
|
service({
|
||||||
80
apps/web/src/lib/presentation-boundary.test.ts
Normal file
80
apps/web/src/lib/presentation-boundary.test.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
595
apps/web/src/lib/server/agent-config/agent-config.test.ts
Normal file
595
apps/web/src/lib/server/agent-config/agent-config.test.ts
Normal file
|
|
@ -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<T>(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<string, unknown> | unknown[];
|
||||||
|
for (const segment of segments) {
|
||||||
|
parent = Array.isArray(parent)
|
||||||
|
? (parent[Number(segment)] as Record<string, unknown> | unknown[])
|
||||||
|
: (parent[segment] as Record<string, unknown> | 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
1292
apps/web/src/lib/server/agent-config/index.ts
Normal file
1292
apps/web/src/lib/server/agent-config/index.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||||
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
|
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
|
||||||
import { loadDashboardRuntime } from "./dashboard";
|
import { loadDashboardRuntime } from "./dashboard";
|
||||||
|
|
||||||
|
|
@ -66,9 +66,6 @@ describe("dashboard runtime loader", () => {
|
||||||
expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain(
|
expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain(
|
||||||
"auto-refresh",
|
"auto-refresh",
|
||||||
);
|
);
|
||||||
expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain(
|
|
||||||
"prompt-registry",
|
|
||||||
);
|
|
||||||
expect(store.listRevisions()).toHaveLength(2);
|
expect(store.listRevisions()).toHaveLength(2);
|
||||||
expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed");
|
expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed");
|
||||||
});
|
});
|
||||||
|
|
@ -138,13 +135,5 @@ function olderDimensionLabSeed() {
|
||||||
...strip,
|
...strip,
|
||||||
items: strip.items.filter((item) => item.id !== "auto-refresh"),
|
items: strip.items.filter((item) => item.id !== "auto-refresh"),
|
||||||
}));
|
}));
|
||||||
document.serviceGroups = document.serviceGroups.map((group) =>
|
|
||||||
group.id === "ai-automation"
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
services: group.services.filter((service) => service.id !== "prompt-registry"),
|
|
||||||
}
|
|
||||||
: group,
|
|
||||||
);
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import type { DashboardDocument } from "$lib/model";
|
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
|
||||||
import {
|
import {
|
||||||
createDashboardStore,
|
createDashboardStore,
|
||||||
DashboardPersistenceValidationError,
|
DashboardPersistenceValidationError,
|
||||||
|
|
@ -18,6 +18,9 @@ export interface DashboardRuntimeReady {
|
||||||
document: DashboardDocument;
|
document: DashboardDocument;
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
currentRevisionId: string;
|
currentRevisionId: string;
|
||||||
|
liveDatasourceHydration?: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardRuntimeEmpty {
|
export interface DashboardRuntimeEmpty {
|
||||||
|
|
@ -2,8 +2,8 @@ import { describe, expect, test, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
DASHBOARD_SCHEMA_VERSION,
|
DASHBOARD_SCHEMA_VERSION,
|
||||||
type DashboardDocument,
|
type DashboardDocument,
|
||||||
} from "$lib/model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
import { resolveDashboardDatasources } from ".";
|
import { resolveDashboardDatasources, resolveDashboardTile } from ".";
|
||||||
|
|
||||||
describe("dashboard datasource resolution", () => {
|
describe("dashboard datasource resolution", () => {
|
||||||
test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => {
|
test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => {
|
||||||
|
|
@ -124,6 +124,100 @@ describe("dashboard datasource resolution", () => {
|
||||||
expect(resolved).not.toBe(testDocument);
|
expect(resolved).not.toBe(testDocument);
|
||||||
expect(testDocument.telemetry[0].value.value).toBe(1);
|
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<Response>((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();
|
const testDocument = testDashboard();
|
||||||
|
|
@ -8,10 +8,40 @@ import type {
|
||||||
StatusItem,
|
StatusItem,
|
||||||
StatusStrip,
|
StatusStrip,
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "$lib/model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
|
|
||||||
|
export type DashboardTileReference =
|
||||||
|
| { kind: "telemetry"; id: string }
|
||||||
|
| { kind: "service"; groupId: string; id: string }
|
||||||
|
| { kind: "module"; id: string }
|
||||||
|
| { kind: "status"; stripId: string; id: string };
|
||||||
|
|
||||||
|
export type DashboardTileItem =
|
||||||
|
| DashboardModule
|
||||||
|
| ServiceEntry
|
||||||
|
| StatusItem
|
||||||
|
| TelemetryCard;
|
||||||
|
|
||||||
|
export type DashboardTileResolution =
|
||||||
|
| {
|
||||||
|
state: "ready";
|
||||||
|
tile: DashboardTileReference;
|
||||||
|
item: DashboardTileItem;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "not_found";
|
||||||
|
tile: DashboardTileReference;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "disabled";
|
||||||
|
tile: DashboardTileReference;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface DatasourceResolutionOptions {
|
export interface DatasourceResolutionOptions {
|
||||||
fetch?: DatasourceFetch;
|
fetch?: DatasourceFetch;
|
||||||
|
now?: () => number;
|
||||||
prometheusBaseUrl?: string;
|
prometheusBaseUrl?: string;
|
||||||
prometheusRangeSeconds?: number;
|
prometheusRangeSeconds?: number;
|
||||||
prometheusStepSeconds?: number;
|
prometheusStepSeconds?: number;
|
||||||
|
|
@ -46,16 +76,90 @@ export async function resolveDashboardDatasources(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveDashboardTile(
|
||||||
|
document: DashboardDocument,
|
||||||
|
tile: DashboardTileReference,
|
||||||
|
options: DatasourceResolutionOptions = {},
|
||||||
|
): Promise<DashboardTileResolution> {
|
||||||
|
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 {
|
interface DatasourceContext {
|
||||||
fetch: DatasourceFetch;
|
fetch: DatasourceFetch;
|
||||||
|
fetchIdentity: number;
|
||||||
|
now: () => number;
|
||||||
prometheusBaseUrl: string;
|
prometheusBaseUrl: string;
|
||||||
prometheusRangeSeconds: number;
|
prometheusRangeSeconds: number;
|
||||||
prometheusStepSeconds: number;
|
prometheusStepSeconds: number;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
|
serviceGroupsSnapshot?: Promise<ServiceGroup[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatasourceFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
type DatasourceFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
interface ServiceGroupsSnapshotEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
snapshot: Promise<ServiceGroup[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceGroupsSnapshotTtlMs = 30_000;
|
||||||
|
const serviceGroupsSnapshotCache = new Map<string, ServiceGroupsSnapshotEntry>();
|
||||||
|
const datasourceFetchIdentities = new WeakMap<DatasourceFetch, number>();
|
||||||
|
let nextDatasourceFetchIdentity = 1;
|
||||||
|
|
||||||
interface PrometheusVectorResult {
|
interface PrometheusVectorResult {
|
||||||
metric?: Record<string, string>;
|
metric?: Record<string, string>;
|
||||||
value?: [number, string];
|
value?: [number, string];
|
||||||
|
|
@ -67,8 +171,12 @@ interface PrometheusMatrixResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
||||||
|
const fetch = options.fetch || globalThis.fetch;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: options.fetch || globalThis.fetch,
|
fetch,
|
||||||
|
fetchIdentity: datasourceFetchIdentity(fetch),
|
||||||
|
now: options.now || Date.now,
|
||||||
prometheusBaseUrl:
|
prometheusBaseUrl:
|
||||||
options.prometheusBaseUrl ||
|
options.prometheusBaseUrl ||
|
||||||
process.env.PROMETHEUS_BASE_URL ||
|
process.env.PROMETHEUS_BASE_URL ||
|
||||||
|
|
@ -79,6 +187,68 @@ function datasourceContext(options: DatasourceResolutionOptions): DatasourceCont
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function datasourceFetchIdentity(fetch: DatasourceFetch): number {
|
||||||
|
const existing = datasourceFetchIdentities.get(fetch);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const next = nextDatasourceFetchIdentity;
|
||||||
|
nextDatasourceFetchIdentity += 1;
|
||||||
|
datasourceFetchIdentities.set(fetch, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceGroupsSnapshot(
|
||||||
|
document: DashboardDocument,
|
||||||
|
context: DatasourceContext,
|
||||||
|
): Promise<ServiceGroup[]> {
|
||||||
|
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(
|
async function resolveTelemetryCard(
|
||||||
card: TelemetryCard,
|
card: TelemetryCard,
|
||||||
context: DatasourceContext,
|
context: DatasourceContext,
|
||||||
|
|
@ -390,6 +560,66 @@ function resolveStatusItem(
|
||||||
return structuredClone(item);
|
return structuredClone(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveStatusTile(
|
||||||
|
item: StatusItem,
|
||||||
|
refreshIntervalSeconds: number | undefined,
|
||||||
|
document: DashboardDocument,
|
||||||
|
context: DatasourceContext,
|
||||||
|
): Promise<StatusItem> {
|
||||||
|
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[]): {
|
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
|
||||||
severity: Severity;
|
severity: Severity;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -673,3 +903,17 @@ function formatDuration(totalSeconds: number): string {
|
||||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||||
return `${days}d ${hours}h ${minutes}m`;
|
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}`;
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||||
import type { DashboardDocument } from "$lib/model";
|
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
|
||||||
import {
|
import {
|
||||||
DashboardPersistenceValidationError,
|
DashboardPersistenceValidationError,
|
||||||
createDashboardStore,
|
createDashboardStore,
|
||||||
|
|
@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
type DashboardDocument,
|
type DashboardDocument,
|
||||||
type DashboardValidationFailure,
|
type DashboardValidationFailure,
|
||||||
} from "$lib/model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
import {
|
import {
|
||||||
type DashboardDatabaseConnection,
|
type DashboardDatabaseConnection,
|
||||||
openDashboardDatabase,
|
openDashboardDatabase,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { DASHBOARD_SCHEMA_VERSION } from "$lib/model";
|
import { DASHBOARD_SCHEMA_VERSION } from "@dimensionlab/dashboard-model";
|
||||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||||
import {
|
import {
|
||||||
UnsupportedDashboardModelVersionError,
|
UnsupportedDashboardModelVersionError,
|
||||||
migrateDashboardDocumentForPersistence,
|
migrateDashboardDocumentForPersistence,
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
validateDashboardDocument,
|
validateDashboardDocument,
|
||||||
type DashboardDocument,
|
type DashboardDocument,
|
||||||
type DashboardValidationFailure,
|
type DashboardValidationFailure,
|
||||||
} from "$lib/model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
|
|
||||||
export interface DashboardModelMigrationSuccess {
|
export interface DashboardModelMigrationSuccess {
|
||||||
valid: true;
|
valid: true;
|
||||||
|
|
@ -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";
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const dashboardDocuments = sqliteTable("dashboard_documents", {
|
export const dashboardDocuments = sqliteTable("dashboard_documents", {
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import { type DashboardDocument } from "@dimensionlab/dashboard-model";
|
||||||
type DashboardDocument,
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
} from "$lib/model";
|
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
|
||||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
|
||||||
import { dashboardDocumentToUiDashboard } from "./model-renderer";
|
import { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||||
|
|
||||||
|
const appRoot = process.cwd().endsWith(`${join("apps", "web")}`)
|
||||||
|
? process.cwd()
|
||||||
|
: join(process.cwd(), "apps", "web");
|
||||||
|
|
||||||
describe("dashboard model renderer", () => {
|
describe("dashboard model renderer", () => {
|
||||||
test("projects the Dimension Lab model into UI component props", () => {
|
test("projects the Dimension Lab model into UI component props", () => {
|
||||||
const dashboard = dashboardDocumentToUiDashboard(dimensionLabDashboardFixture);
|
const dashboard = dashboardDocumentToUiDashboard(dimensionLabDashboardFixture);
|
||||||
|
|
@ -86,7 +88,13 @@ describe("dashboard model renderer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not hardcode environment-specific content in mapper source", () => {
|
test("does not hardcode environment-specific content in mapper source", () => {
|
||||||
const source = readFileSync(join(process.cwd(), "src/lib/ui/model-renderer.ts"), "utf8").toLowerCase();
|
const source = readFileSync(
|
||||||
|
join(appRoot, "src/lib/ui-adapter/model-renderer.ts"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model")
|
||||||
|
.replaceAll("@dimensionlab/ui", "@internal/ui");
|
||||||
|
|
||||||
expect(source).not.toContain("dimension");
|
expect(source).not.toContain("dimension");
|
||||||
expect(source).not.toContain("vaultwarden");
|
expect(source).not.toContain("vaultwarden");
|
||||||
|
|
@ -6,7 +6,7 @@ import type {
|
||||||
StatusItem,
|
StatusItem,
|
||||||
StatusStrip,
|
StatusStrip,
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "$lib/model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
import type {
|
import type {
|
||||||
UiDashboardPreview,
|
UiDashboardPreview,
|
||||||
UiModuleBlock,
|
UiModuleBlock,
|
||||||
|
|
@ -14,7 +14,7 @@ import type {
|
||||||
UiServiceRow,
|
UiServiceRow,
|
||||||
UiStatusItem,
|
UiStatusItem,
|
||||||
UiTelemetryCard,
|
UiTelemetryCard,
|
||||||
} from "./types";
|
} from "@dimensionlab/ui";
|
||||||
|
|
||||||
export function dashboardDocumentToUiDashboard(
|
export function dashboardDocumentToUiDashboard(
|
||||||
document: DashboardDocument,
|
document: DashboardDocument,
|
||||||
578
apps/web/src/lib/workspace-boundary.test.ts
Normal file
578
apps/web/src/lib/workspace-boundary.test.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
};
|
||||||
|
const webPackage = JSON.parse(
|
||||||
|
readFileSync(join(root, "apps/web/package.json"), "utf8"),
|
||||||
|
) as { scripts?: Record<string, string> };
|
||||||
|
const turboConfig = JSON.parse(
|
||||||
|
readFileSync(join(root, "turbo.json"), "utf8"),
|
||||||
|
) as {
|
||||||
|
globalDependencies?: string[];
|
||||||
|
tasks?: Record<string, { env?: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, { dependsOn?: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, { inputs?: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string>; name?: string };
|
||||||
|
const uiPackage = JSON.parse(
|
||||||
|
readFileSync(join(root, "packages/ui/package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, unknown>; 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<string, string> };
|
||||||
|
const modelPackage = JSON.parse(
|
||||||
|
readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, unknown>; 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<string, string[]> } };
|
||||||
|
const modelPackage = JSON.parse(
|
||||||
|
readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, WorkspacePackageExport> };
|
||||||
|
const uiPackage = JSON.parse(
|
||||||
|
readFileSync(join(root, "packages/ui/package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, WorkspacePackageExport> };
|
||||||
|
const viteConfig = readFileSync(join(root, "apps/web/vite.config.ts"), "utf8");
|
||||||
|
const turboConfig = JSON.parse(
|
||||||
|
readFileSync(join(root, "turbo.json"), "utf8"),
|
||||||
|
) as { tasks?: Record<string, { dependsOn?: string[] }> };
|
||||||
|
|
||||||
|
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<string, string> };
|
||||||
|
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<WorkspacePackageExport, string>,
|
||||||
|
): void {
|
||||||
|
expect(actual).toMatchObject(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDeployScriptWithFakes(
|
||||||
|
commands: Record<string, string>,
|
||||||
|
env: Record<string, string>,
|
||||||
|
): { 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
|
||||||
|
`;
|
||||||
|
}
|
||||||
13
apps/web/src/main.tsx
Normal file
13
apps/web/src/main.tsx
Normal file
|
|
@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
97
apps/web/src/page.test.tsx
Normal file
97
apps/web/src/page.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "ready",
|
||||||
|
document: genericDashboardFixture,
|
||||||
|
schemaVersion: "dashboard.v1",
|
||||||
|
currentRevisionId: "revision-1234567890",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "invalid",
|
||||||
|
title: "Invalid Dashboard",
|
||||||
|
subtitle: "Validation failed",
|
||||||
|
message: "Dashboard document is invalid.",
|
||||||
|
errors: ["/metadata/title is required"],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "empty",
|
||||||
|
title: "No Dashboard Model",
|
||||||
|
subtitle: "No active document",
|
||||||
|
message: "No validated dashboard document is active yet.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const loading = renderToString(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "loading",
|
||||||
|
title: "Loading Dashboard",
|
||||||
|
subtitle: "Fetching active model",
|
||||||
|
message: "Waiting for the active dashboard document.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(empty).toContain("No Dashboard Model");
|
||||||
|
expect(empty).toContain("No active document");
|
||||||
|
expect(loading).toContain("Loading Dashboard");
|
||||||
|
expect(loading).toContain("Fetching active model");
|
||||||
|
});
|
||||||
|
});
|
||||||
33
apps/web/src/server/dev.test.ts
Normal file
33
apps/web/src/server/dev.test.ts
Normal file
|
|
@ -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<string, string> };
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/web/src/server/dev.ts
Normal file
76
apps/web/src/server/dev.ts
Normal file
|
|
@ -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<ReturnType<typeof Bun.spawn>> = [];
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
function spawn(
|
||||||
|
label: string,
|
||||||
|
command: string[],
|
||||||
|
env: Record<string, string> = {},
|
||||||
|
): 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}`);
|
||||||
|
}
|
||||||
77
apps/web/src/server/index.test.ts
Normal file
77
apps/web/src/server/index.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/web/src/server/index.ts
Normal file
104
apps/web/src/server/index.ts
Normal file
|
|
@ -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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
12
apps/web/src/server/routes/agent-dashboard.test.ts
Normal file
12
apps/web/src/server/routes/agent-dashboard.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
apps/web/src/server/routes/agent-dashboard.ts
Normal file
5
apps/web/src/server/routes/agent-dashboard.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { handleAgentDashboardRequest } from "$lib/server/agent-config";
|
||||||
|
|
||||||
|
export function handleAgentDashboardRoute(request: Request): Promise<Response> {
|
||||||
|
return handleAgentDashboardRequest(request);
|
||||||
|
}
|
||||||
799
apps/web/src/server/routes/dashboard.test.ts
Normal file
799
apps/web/src/server/routes/dashboard.test.ts
Normal file
|
|
@ -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<Response>((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<never>((_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<string, () => 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<void>((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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
609
apps/web/src/server/routes/dashboard.ts
Normal file
609
apps/web/src/server/routes/dashboard.ts
Normal file
|
|
@ -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<DashboardTileResolution>,
|
||||||
|
): Promise<DashboardTileCacheResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, DashboardTileCacheEntry>();
|
||||||
|
const inFlight = new Map<string, Promise<DashboardTileResolution>>();
|
||||||
|
|
||||||
|
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<DashboardRuntimeState> {
|
||||||
|
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<DashboardTileResolution> {
|
||||||
|
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<DashboardTilesBatchResponse> {
|
||||||
|
return {
|
||||||
|
state: "ready",
|
||||||
|
tiles: await resolveDashboardTilesBatch(tiles, options),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDashboardRoute(): Promise<Response> {
|
||||||
|
return Response.json(await loadDashboardResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDashboardTileRoute(
|
||||||
|
pathname: string,
|
||||||
|
options: LoadDashboardResponseOptions = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
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<DashboardTileResolution[]> {
|
||||||
|
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<DashboardTileReference[] | null> {
|
||||||
|
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<DashboardTileCacheResult, "cache" | "coalesced">,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
3
apps/web/src/vite-env.d.ts
vendored
Normal file
3
apps/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.css" {}
|
||||||
381
apps/web/tests/e2e/dashboard.spec.ts
Normal file
381
apps/web/tests/e2e/dashboard.spec.ts
Normal file
|
|
@ -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<HTMLElement>(".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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
35
apps/web/tests/e2e/storybook-server.ts
Normal file
35
apps/web/tests/e2e/storybook-server.ts
Normal file
|
|
@ -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}`);
|
||||||
40
apps/web/tests/e2e/storybook.spec.ts
Normal file
40
apps/web/tests/e2e/storybook.spec.ts
Normal file
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/web/tsconfig.json
Normal file
19
apps/web/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
34
apps/web/vite.config.ts
Normal file
34
apps/web/vite.config.ts
Normal file
|
|
@ -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/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
592
docs/superpowers/plans/2026-06-19-react-runtime-migration.md
Normal file
592
docs/superpowers/plans/2026-06-19-react-runtime-migration.md
Normal file
|
|
@ -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 `<div id="root"></div>` 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(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "loading",
|
||||||
|
title: "Loading Dashboard",
|
||||||
|
subtitle: "Fetching active model",
|
||||||
|
message: "Waiting for the active dashboard document.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dimension Lab</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create minimal `src/App.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
|
|
||||||
|
export function AppStateView({ dashboard }: { dashboard: DashboardRuntimeState }) {
|
||||||
|
if (dashboard.state === "ready") {
|
||||||
|
return <main>{dashboard.document.metadata.title}</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="state-shell" data-dashboard-state={dashboard.state}>
|
||||||
|
<h1>{dashboard.title}</h1>
|
||||||
|
<p>{dashboard.subtitle}</p>
|
||||||
|
<p>{dashboard.message}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "loading",
|
||||||
|
title: "Loading Dashboard",
|
||||||
|
subtitle: "Fetching active model",
|
||||||
|
message: "Waiting for the active dashboard document.",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
436
docs/superpowers/plans/2026-06-20-turbo-component-library.md
Normal file
436
docs/superpowers/plans/2026-06-20-turbo-component-library.md
Normal file
|
|
@ -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<string, string>;
|
||||||
|
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<string, string>; name?: string };
|
||||||
|
const uiPackage = JSON.parse(
|
||||||
|
readFileSync(join(root, "packages/ui/package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, unknown>; 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`.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
64
package.json
64
package.json
|
|
@ -1,54 +1,28 @@
|
||||||
{
|
{
|
||||||
"name": "dimensionlab-website",
|
"name": "dimensionlab",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "bun@1.3.14",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "turbo run dev --filter=@dimensionlab/web",
|
||||||
"build": "vite build",
|
"build": "turbo run build",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "turbo run preview --filter=@dimensionlab/web",
|
||||||
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
"storybook": "turbo run storybook --filter=@dimensionlab/ui",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "turbo run build-storybook --filter=@dimensionlab/ui",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "turbo run check",
|
||||||
"test": "vitest run",
|
"test": "turbo run test:unit",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "turbo run test:unit",
|
||||||
"test:e2e": "env -u NO_COLOR playwright test",
|
"test:e2e": "turbo run test:e2e --filter=@dimensionlab/web",
|
||||||
"test:qa": "bun run check && bun run test:unit && bun run build && bun run build-storybook && bun run test:e2e",
|
"test:qa": "turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "turbo run db:generate --filter=@dimensionlab/web",
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "turbo run db:check --filter=@dimensionlab/web"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@iconify/svelte": "^5.2.2",
|
|
||||||
"@sinclair/typebox": "^0.34.49",
|
|
||||||
"ajv": "^8.20.0",
|
|
||||||
"ajv-formats": "^3.0.1",
|
|
||||||
"drizzle-orm": "^0.45.2",
|
|
||||||
"uplot": "^1.6.32"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.11.3",
|
"turbo": "^2.5.0"
|
||||||
"@playwright/test": "^1.61.0",
|
|
||||||
"@storybook/addon-a11y": "^10.4.6",
|
|
||||||
"@storybook/addon-svelte-csf": "^5.1.2",
|
|
||||||
"@storybook/addon-vitest": "^10.4.6",
|
|
||||||
"@storybook/sveltekit": "^10.4.6",
|
|
||||||
"@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",
|
|
||||||
"msw": "^2.14.6",
|
|
||||||
"storybook": "^10.4.6",
|
|
||||||
"svelte": "^5.56.3",
|
|
||||||
"svelte-check": "^4.6.0",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.16",
|
|
||||||
"vitest": "^4.1.9"
|
|
||||||
},
|
|
||||||
"msw": {
|
|
||||||
"workerDirectory": [
|
|
||||||
"static"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
packages/dashboard-model/package.json
Normal file
36
packages/dashboard-model/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/dashboard-model/src/fixtures/index.ts
Normal file
1
packages/dashboard-model/src/fixtures/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { genericDashboardFixture } from "./generic";
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
export {
|
export {
|
||||||
DASHBOARD_SCHEMA_VERSION,
|
DASHBOARD_SCHEMA_VERSION,
|
||||||
DashboardDocumentSchema,
|
DashboardDocumentSchema,
|
||||||
|
DatasourceReferenceSchema,
|
||||||
|
ServiceEntrySchema,
|
||||||
|
ServiceGroupSchema,
|
||||||
|
TelemetryCardSchema,
|
||||||
|
ThresholdSchema,
|
||||||
dashboardDocumentJsonSchema,
|
dashboardDocumentJsonSchema,
|
||||||
type DashboardDocument,
|
type DashboardDocument,
|
||||||
type DashboardModule,
|
type DashboardModule,
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
validateDashboardDocument,
|
validateDashboardDocument,
|
||||||
} from ".";
|
} from ".";
|
||||||
import {
|
import {
|
||||||
dimensionLabDashboardFixture,
|
|
||||||
genericDashboardFixture,
|
genericDashboardFixture,
|
||||||
} from "./fixtures";
|
} 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", () => {
|
it("rejects documents with an unsupported schema version", () => {
|
||||||
const invalid = {
|
const invalid = {
|
||||||
...genericDashboardFixture,
|
...genericDashboardFixture,
|
||||||
|
|
@ -27,7 +27,7 @@ const LinkSchema = Type.Object(
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const StaticDatasourceSchema = Type.Object(
|
export const StaticDatasourceSchema = Type.Object(
|
||||||
{
|
{
|
||||||
type: Type.Literal("static"),
|
type: Type.Literal("static"),
|
||||||
label: Type.Optional(Type.String({ minLength: 1 })),
|
label: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
|
@ -36,7 +36,7 @@ const StaticDatasourceSchema = Type.Object(
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const PlaceholderDatasourceSchema = Type.Object(
|
export const PlaceholderDatasourceSchema = Type.Object(
|
||||||
{
|
{
|
||||||
type: Type.Literal("placeholder"),
|
type: Type.Literal("placeholder"),
|
||||||
reason: Type.String({ minLength: 1 }),
|
reason: Type.String({ minLength: 1 }),
|
||||||
|
|
@ -44,7 +44,7 @@ const PlaceholderDatasourceSchema = Type.Object(
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExternalDatasourceSchema = Type.Object(
|
export const ExternalDatasourceSchema = Type.Object(
|
||||||
{
|
{
|
||||||
type: Type.Literal("external"),
|
type: Type.Literal("external"),
|
||||||
adapter: Type.Union([
|
adapter: Type.Union([
|
||||||
|
|
@ -58,7 +58,7 @@ const ExternalDatasourceSchema = Type.Object(
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const DatasourceReferenceSchema = Type.Union([
|
export const DatasourceReferenceSchema = Type.Union([
|
||||||
StaticDatasourceSchema,
|
StaticDatasourceSchema,
|
||||||
PlaceholderDatasourceSchema,
|
PlaceholderDatasourceSchema,
|
||||||
ExternalDatasourceSchema,
|
ExternalDatasourceSchema,
|
||||||
|
|
@ -98,13 +98,13 @@ const TextMetricValueSchema = Type.Object(
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const MetricValueSchema = Type.Union([
|
export const MetricValueSchema = Type.Union([
|
||||||
PercentMetricValueSchema,
|
PercentMetricValueSchema,
|
||||||
NonNegativeMetricValueSchema,
|
NonNegativeMetricValueSchema,
|
||||||
TextMetricValueSchema,
|
TextMetricValueSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ThresholdSchema = Type.Object(
|
export const ThresholdSchema = Type.Object(
|
||||||
{
|
{
|
||||||
warning: Type.Optional(Type.Number({ minimum: 0 })),
|
warning: Type.Optional(Type.Number({ minimum: 0 })),
|
||||||
danger: Type.Optional(Type.Number({ minimum: 0 })),
|
danger: Type.Optional(Type.Number({ minimum: 0 })),
|
||||||
12
packages/dashboard-model/tsconfig.build.json
Normal file
12
packages/dashboard-model/tsconfig.build.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
9
packages/dashboard-model/tsconfig.json
Normal file
9
packages/dashboard-model/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": ["node", "bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
1
packages/ui
Submodule
1
packages/ui
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit a7a472083555152b8e1a2dc018d3be5b3b10d60b
|
||||||
362
scripts/deploy-dimensionlab-website.sh
Executable file
362
scripts/deploy-dimensionlab-website.sh
Executable file
|
|
@ -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 <<USAGE
|
||||||
|
Usage: $0 [--dry-run]
|
||||||
|
|
||||||
|
Build and deploy ${APP_NAME} for the production ${SERVICE_NAME} unit.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Print the deployment actions without changing the host.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[deploy:%s] %s\n' "$APP_NAME" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '[deploy:%s] ERROR: %s\n' "$APP_NAME" "$*" >&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"
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
@import "./lib/ui/tokens.css";
|
|
||||||
11
src/app.html
11
src/app.html
|
|
@ -1,11 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { dimensionLabDashboardFixture } from "./dimensionlab";
|
|
||||||
export { genericDashboardFixture } from "./generic";
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
const presentationRoots = [
|
|
||||||
join(process.cwd(), "src", "lib", "ui"),
|
|
||||||
join(process.cwd(), "src", "routes"),
|
|
||||||
];
|
|
||||||
|
|
||||||
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 = presentationRoots.map(readPresentationSource).join("\n").toLowerCase();
|
|
||||||
|
|
||||||
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function readPresentationSource(path: string): string {
|
|
||||||
const stats = statSync(path);
|
|
||||||
if (stats.isFile()) {
|
|
||||||
if (path.endsWith(".test.ts")) return "";
|
|
||||||
if (path.includes(`${join("src", "lib", "ui", "stories")}${"/"}`)) return "";
|
|
||||||
if (!/\.(svelte|svelte\.js|ts|js|mjs|css|json)$/.test(path)) return "";
|
|
||||||
return readFileSync(path, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
return readdirSync(path)
|
|
||||||
.map((entry) => readPresentationSource(join(path, entry)))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiSeverity } from "../types";
|
|
||||||
import StatusBadge from "./StatusBadge.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
severity = "neutral",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
severity?: UiSeverity;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<StatusBadge {label} {severity} />
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
|
||||||
import IconGlyph from "./IconGlyph.svelte";
|
|
||||||
|
|
||||||
type ButtonProps = Omit<HTMLButtonAttributes, "type"> & {
|
|
||||||
label: string;
|
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
|
||||||
size?: "default" | "compact";
|
|
||||||
icon?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
type?: "button" | "submit" | "reset";
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
variant = "primary",
|
|
||||||
size = "default",
|
|
||||||
icon,
|
|
||||||
disabled = false,
|
|
||||||
loading = false,
|
|
||||||
type = "button",
|
|
||||||
class: className = "",
|
|
||||||
...buttonProps
|
|
||||||
}: ButtonProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
{...buttonProps}
|
|
||||||
class={className ? `ui-button ${className}` : "ui-button"}
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
data-loading={loading}
|
|
||||||
{disabled}
|
|
||||||
{type}
|
|
||||||
>
|
|
||||||
{#if icon}
|
|
||||||
<IconGlyph name={icon} size="sm" />
|
|
||||||
{/if}
|
|
||||||
<span>{loading ? "Loading" : label}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.ui-button {
|
|
||||||
display: inline-grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
gap: var(--ui-space-2);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background: var(--ui-color-accent);
|
|
||||||
color: var(--ui-color-canvas);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: 0;
|
|
||||||
padding: 0 var(--ui-space-4);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-button[data-size="compact"] {
|
|
||||||
min-height: 2rem;
|
|
||||||
padding-inline: var(--ui-space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-button[data-variant="secondary"] {
|
|
||||||
background: var(--ui-color-surface-raised);
|
|
||||||
color: var(--ui-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-button[data-variant="danger"] {
|
|
||||||
background: var(--ui-color-danger);
|
|
||||||
color: var(--ui-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-button[data-variant="ghost"] {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.48;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
density = "regular",
|
|
||||||
size = "md",
|
|
||||||
tone = "neutral",
|
|
||||||
}: {
|
|
||||||
density?: "regular" | "tight";
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
tone?: "neutral" | "accent" | "danger";
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="corner-bracket-frame"
|
|
||||||
data-density={density}
|
|
||||||
data-size={size}
|
|
||||||
data-tone={tone}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span data-corner="top-left"></span>
|
|
||||||
<span data-corner="top-right"></span>
|
|
||||||
<span data-corner="bottom-left"></span>
|
|
||||||
<span data-corner="bottom-right"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.corner-bracket-frame {
|
|
||||||
--corner-color: var(--ui-color-line-strong);
|
|
||||||
--corner-offset: 0.5rem;
|
|
||||||
--corner-size: 1.45rem;
|
|
||||||
position: relative;
|
|
||||||
min-height: 5rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, color-mix(in srgb, var(--corner-color), transparent 78%) 1px, transparent 1px),
|
|
||||||
linear-gradient(color-mix(in srgb, var(--corner-color), transparent 84%) 1px, transparent 1px),
|
|
||||||
rgba(2, 3, 2, 0.64);
|
|
||||||
background-size: 2rem 2rem, 2rem 2rem, auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
position: absolute;
|
|
||||||
width: var(--corner-size);
|
|
||||||
height: var(--corner-size);
|
|
||||||
border-color: var(--corner-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-corner="top-left"] {
|
|
||||||
top: var(--corner-offset);
|
|
||||||
left: var(--corner-offset);
|
|
||||||
border-top: 1px solid;
|
|
||||||
border-left: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-corner="top-right"] {
|
|
||||||
top: var(--corner-offset);
|
|
||||||
right: var(--corner-offset);
|
|
||||||
border-top: 1px solid;
|
|
||||||
border-right: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-corner="bottom-left"] {
|
|
||||||
bottom: var(--corner-offset);
|
|
||||||
left: var(--corner-offset);
|
|
||||||
border-bottom: 1px solid;
|
|
||||||
border-left: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-corner="bottom-right"] {
|
|
||||||
right: var(--corner-offset);
|
|
||||||
bottom: var(--corner-offset);
|
|
||||||
border-right: 1px solid;
|
|
||||||
border-bottom: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-bracket-frame[data-density="tight"] {
|
|
||||||
--corner-offset: 0.28rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-bracket-frame[data-size="sm"] {
|
|
||||||
--corner-size: 0.95rem;
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-bracket-frame[data-size="lg"] {
|
|
||||||
--corner-size: 2.25rem;
|
|
||||||
min-height: 8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-bracket-frame[data-tone="accent"] {
|
|
||||||
--corner-color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-bracket-frame[data-tone="danger"] {
|
|
||||||
--corner-color: var(--ui-color-danger);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiDashboardPreview } from "../types";
|
|
||||||
import ModuleCard from "./ModuleCard.svelte";
|
|
||||||
import ServicePanel from "./ServicePanel.svelte";
|
|
||||||
import StatusStrip from "./StatusStrip.svelte";
|
|
||||||
import TelemetryGrid from "./TelemetryGrid.svelte";
|
|
||||||
|
|
||||||
const generatedTitleId = $props.id();
|
|
||||||
|
|
||||||
let {
|
|
||||||
dashboard,
|
|
||||||
titleId = `${generatedTitleId}-title`,
|
|
||||||
}: {
|
|
||||||
dashboard: UiDashboardPreview;
|
|
||||||
titleId?: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="dashboard-frame" aria-labelledby={titleId}>
|
|
||||||
<header class="dashboard-frame__header">
|
|
||||||
<div>
|
|
||||||
{#if dashboard.eyebrow}
|
|
||||||
<p>{dashboard.eyebrow}</p>
|
|
||||||
{/if}
|
|
||||||
<h1 id={titleId}>{dashboard.title}</h1>
|
|
||||||
{#if dashboard.subtitle}
|
|
||||||
<span>{dashboard.subtitle}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if dashboard.modules.length}
|
|
||||||
<div class="dashboard-frame__modules">
|
|
||||||
{#each dashboard.modules as module (module.id)}
|
|
||||||
<ModuleCard {module} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<TelemetryGrid cards={dashboard.telemetry} />
|
|
||||||
|
|
||||||
<section class="dashboard-frame__panels" aria-label="Service groups">
|
|
||||||
{#each dashboard.serviceGroups as group (group.id)}
|
|
||||||
<ServicePanel {group} />
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<StatusStrip id={dashboard.statusStripId} items={dashboard.statusItems} />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dashboard-frame {
|
|
||||||
display: grid;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 100vh;
|
|
||||||
min-height: 100vh;
|
|
||||||
gap: 0.36rem;
|
|
||||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: clamp(0.42rem, 0.65vw, 0.62rem);
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
|
|
||||||
rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(30rem, 1fr) minmax(30rem, 0.9fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: start;
|
|
||||||
border-bottom: var(--ui-border);
|
|
||||||
min-height: 5.35rem;
|
|
||||||
padding-bottom: 0.34rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__header p,
|
|
||||||
.dashboard-frame__header span,
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__header p,
|
|
||||||
.dashboard-frame__header span {
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-family: var(--ui-font-display);
|
|
||||||
font-size: clamp(2.35rem, 3.65vw, 3.25rem);
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 0.78;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__modules {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(15rem, 0.66fr) minmax(17rem, 1fr);
|
|
||||||
justify-content: end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__panels {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
align-content: start;
|
|
||||||
gap: 0.36rem;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__panels :global(.service-panel[data-layout="grid"]) {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__panels
|
|
||||||
:global(.service-panel[data-layout="grid"] .panel__body) {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.dashboard-frame {
|
|
||||||
height: auto;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__header {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__modules {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__panels {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-frame__panels
|
|
||||||
:global(.service-panel[data-layout="grid"] .panel__body) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiModuleBlock } from "../types";
|
|
||||||
import ModuleCard from "./ModuleCard.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
eyebrow,
|
|
||||||
module,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
eyebrow?: string;
|
|
||||||
module?: UiModuleBlock;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const titleId = $props.id();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header class="dashboard-header" aria-labelledby={titleId}>
|
|
||||||
<div>
|
|
||||||
{#if eyebrow}
|
|
||||||
<p>{eyebrow}</p>
|
|
||||||
{/if}
|
|
||||||
<h1 id={titleId}>{title}</h1>
|
|
||||||
{#if subtitle}
|
|
||||||
<span>{subtitle}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if module}
|
|
||||||
<ModuleCard {module} />
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dashboard-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: var(--ui-space-4);
|
|
||||||
align-items: start;
|
|
||||||
border-bottom: var(--ui-border);
|
|
||||||
padding: var(--ui-space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
span,
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
span {
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-family: var(--ui-font-display);
|
|
||||||
font-size: clamp(2.45rem, 5vw, 4.2rem);
|
|
||||||
line-height: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.dashboard-header {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
density = "regular",
|
|
||||||
direction = "forward",
|
|
||||||
size = "md",
|
|
||||||
tone = "accent",
|
|
||||||
}: {
|
|
||||||
density?: "open" | "regular" | "tight";
|
|
||||||
direction?: "forward" | "backward";
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
tone?: "neutral" | "accent" | "warning" | "danger";
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="diagonal-stripe-field"
|
|
||||||
data-density={density}
|
|
||||||
data-direction={direction}
|
|
||||||
data-size={size}
|
|
||||||
data-tone={tone}
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.diagonal-stripe-field {
|
|
||||||
--stripe-angle: 135deg;
|
|
||||||
--stripe-color: var(--ui-color-line-strong);
|
|
||||||
--stripe-gap: 1.1rem;
|
|
||||||
--stripe-width: 0.18rem;
|
|
||||||
display: block;
|
|
||||||
min-height: 3rem;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background:
|
|
||||||
repeating-linear-gradient(
|
|
||||||
var(--stripe-angle),
|
|
||||||
transparent 0 var(--stripe-gap),
|
|
||||||
color-mix(in srgb, var(--stripe-color), transparent 26%) var(--stripe-gap)
|
|
||||||
calc(var(--stripe-gap) + var(--stripe-width))
|
|
||||||
),
|
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.05), transparent 42%),
|
|
||||||
rgba(2, 3, 2, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-direction="backward"] {
|
|
||||||
--stripe-angle: 45deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-density="open"] {
|
|
||||||
--stripe-gap: 1.65rem;
|
|
||||||
--stripe-width: 0.16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-density="tight"] {
|
|
||||||
--stripe-gap: 0.72rem;
|
|
||||||
--stripe-width: 0.14rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-size="sm"] {
|
|
||||||
min-height: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-size="lg"] {
|
|
||||||
min-height: 5.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-tone="accent"] {
|
|
||||||
--stripe-color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-tone="warning"] {
|
|
||||||
--stripe-color: var(--ui-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagonal-stripe-field[data-tone="danger"] {
|
|
||||||
--stripe-color: var(--ui-color-danger);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiStatusItem } from "../types";
|
|
||||||
|
|
||||||
let { item }: { item: UiStatusItem } = $props();
|
|
||||||
|
|
||||||
let target = $derived(item.link?.external ? "_blank" : undefined);
|
|
||||||
let rel = $derived(item.link?.external ? "noreferrer" : undefined);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet cellContent()}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{item.value}</strong>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#if item.link}
|
|
||||||
<a
|
|
||||||
class="footer-cell"
|
|
||||||
data-severity={item.severity || "neutral"}
|
|
||||||
data-model-id={item.id}
|
|
||||||
href={item.link.href}
|
|
||||||
target={target}
|
|
||||||
rel={rel}
|
|
||||||
aria-label={item.link.label}
|
|
||||||
>
|
|
||||||
{@render cellContent()}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="footer-cell" data-severity={item.severity || "neutral"} data-model-id={item.id}>
|
|
||||||
{@render cellContent()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.footer-cell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
min-height: 1.9rem;
|
|
||||||
align-items: center;
|
|
||||||
background: rgba(2, 3, 2, 0.92);
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span,
|
|
||||||
strong {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.34rem 0.52rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
height: 100%;
|
|
||||||
border-right: var(--ui-border);
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
font-size: 0.52rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: var(--ui-color-text);
|
|
||||||
font-size: 0.58rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cell[data-severity="ok"] strong {
|
|
||||||
background: var(--ui-color-accent);
|
|
||||||
color: #060706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cell[data-severity="warning"] strong {
|
|
||||||
color: var(--ui-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cell[data-severity="danger"] strong {
|
|
||||||
color: var(--ui-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cell[data-severity="stale"] strong,
|
|
||||||
.footer-cell[data-severity="unavailable"] strong {
|
|
||||||
color: var(--ui-color-stale);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cell[data-severity="loading"] strong {
|
|
||||||
color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cell:where(a):hover strong {
|
|
||||||
color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiStatusItem } from "../types";
|
|
||||||
import FooterCell from "./FooterCell.svelte";
|
|
||||||
|
|
||||||
let { item }: { item: UiStatusItem } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FooterCell {item} />
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
children,
|
|
||||||
density = "dense",
|
|
||||||
}: {
|
|
||||||
children?: Snippet;
|
|
||||||
density?: "compact" | "dense";
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="grid-frame" data-density={density}>
|
|
||||||
{@render children?.()}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.grid-frame {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
|
||||||
gap: var(--ui-space-3);
|
|
||||||
padding: var(--ui-space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-frame[data-density="compact"] {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
|
||||||
gap: var(--ui-space-2);
|
|
||||||
padding: var(--ui-space-2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
|
||||||
import IconGlyph from "./IconGlyph.svelte";
|
|
||||||
|
|
||||||
type IconButtonProps = Omit<HTMLButtonAttributes, "type"> & {
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
active?: boolean;
|
|
||||||
type?: "button" | "submit" | "reset";
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
active = false,
|
|
||||||
disabled = false,
|
|
||||||
type = "button",
|
|
||||||
class: className = "",
|
|
||||||
...buttonProps
|
|
||||||
}: IconButtonProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
{...buttonProps}
|
|
||||||
class={className ? `icon-button ${className}` : "icon-button"}
|
|
||||||
aria-label={label}
|
|
||||||
data-active={active}
|
|
||||||
{disabled}
|
|
||||||
{type}
|
|
||||||
>
|
|
||||||
<IconGlyph name={icon} size="sm" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.icon-button {
|
|
||||||
display: inline-grid;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
place-items: center;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background: rgba(2, 3, 2, 0.92);
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button[data-active="true"],
|
|
||||||
.icon-button:hover {
|
|
||||||
border-color: var(--ui-color-accent);
|
|
||||||
color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
size = "md",
|
|
||||||
}: {
|
|
||||||
name?: string;
|
|
||||||
label?: string;
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="icon-glyph"
|
|
||||||
data-size={size}
|
|
||||||
data-icon-name={name}
|
|
||||||
aria-label={label}
|
|
||||||
aria-hidden={label ? undefined : "true"}
|
|
||||||
>
|
|
||||||
{#if name}
|
|
||||||
<Icon icon={name} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.icon-glyph {
|
|
||||||
display: inline-grid;
|
|
||||||
width: 1.55rem;
|
|
||||||
height: 1.55rem;
|
|
||||||
place-items: center;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background: #050605;
|
|
||||||
color: currentColor;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-glyph :global(svg) {
|
|
||||||
width: 0.95rem;
|
|
||||||
height: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-glyph[data-size="sm"] {
|
|
||||||
width: 1.1rem;
|
|
||||||
height: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-glyph[data-size="lg"] {
|
|
||||||
width: 2.35rem;
|
|
||||||
height: 2.35rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
import "uplot/dist/uPlot.min.css";
|
|
||||||
import type { UiSeverity } from "../types";
|
|
||||||
|
|
||||||
let {
|
|
||||||
values = [],
|
|
||||||
severity = "neutral",
|
|
||||||
label = "Telemetry trend",
|
|
||||||
}: {
|
|
||||||
values?: number[];
|
|
||||||
severity?: UiSeverity;
|
|
||||||
label?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let chartElement: HTMLDivElement;
|
|
||||||
let chart: ChartInstance | null = null;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const module = await import("uplot");
|
|
||||||
chart = new module.default(chartOptions(chartElement, severity), chartData(values), chartElement);
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
chart?.setSize(chartSize(chartElement));
|
|
||||||
});
|
|
||||||
resizeObserver.observe(chartElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
chart?.setData(chartData(values));
|
|
||||||
chart?.setSize(chartSize(chartElement));
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
chart?.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="line-chart"
|
|
||||||
data-chart-library="uplot"
|
|
||||||
data-severity={severity}
|
|
||||||
role="img"
|
|
||||||
aria-label={label}
|
|
||||||
>
|
|
||||||
<div class="line-chart__canvas" bind:this={chartElement} aria-hidden="true"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.line-chart {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
height: 1rem;
|
|
||||||
color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart[data-severity="warning"] {
|
|
||||||
color: var(--ui-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart[data-severity="danger"] {
|
|
||||||
color: var(--ui-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart[data-severity="stale"],
|
|
||||||
.line-chart[data-severity="unavailable"] {
|
|
||||||
color: var(--ui-color-stale);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart__canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart__canvas :global(.uplot) {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
background: transparent;
|
|
||||||
font-family: var(--ui-font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart__canvas :global(.u-over),
|
|
||||||
.line-chart__canvas :global(.u-under) {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script lang="ts" module>
|
|
||||||
type ChartInstance = {
|
|
||||||
destroy(): void;
|
|
||||||
setData(data: import("uplot").AlignedData): void;
|
|
||||||
setSize(size: { width: number; height: number }): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function chartData(values: number[]): import("uplot").AlignedData {
|
|
||||||
const normalized = values.length ? values : [0, 0];
|
|
||||||
return [
|
|
||||||
normalized.map((_, index) => index),
|
|
||||||
normalized.map((value) => Math.max(0, Number(value) || 0)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function chartOptions(
|
|
||||||
element: HTMLElement,
|
|
||||||
severity: import("../types").UiSeverity,
|
|
||||||
): import("uplot").Options {
|
|
||||||
return {
|
|
||||||
...chartSize(element),
|
|
||||||
cursor: { show: false },
|
|
||||||
legend: { show: false },
|
|
||||||
padding: [2, 0, 2, 0],
|
|
||||||
scales: {
|
|
||||||
x: { time: false },
|
|
||||||
y: { auto: true },
|
|
||||||
},
|
|
||||||
axes: [
|
|
||||||
{ show: false },
|
|
||||||
{ show: false },
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
stroke: chartStroke(element, severity),
|
|
||||||
width: 2,
|
|
||||||
points: { show: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function chartSize(element?: HTMLElement): { width: number; height: number } {
|
|
||||||
return {
|
|
||||||
width: Math.max(80, Math.round(element?.clientWidth || 120)),
|
|
||||||
height: Math.max(20, Math.round(element?.clientHeight || 24)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function chartStroke(element: HTMLElement, severity: import("../types").UiSeverity): string {
|
|
||||||
const styles = window.getComputedStyle(element.closest(".line-chart") || element);
|
|
||||||
const currentColor = styles.color;
|
|
||||||
if (currentColor) return currentColor;
|
|
||||||
if (severity === "danger") return "#ff1744";
|
|
||||||
if (severity === "warning") return "#ffb020";
|
|
||||||
return "#d7ff00";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiModuleBlock } from "../types";
|
|
||||||
import IconGlyph from "./IconGlyph.svelte";
|
|
||||||
|
|
||||||
const generatedId = $props.id();
|
|
||||||
|
|
||||||
let { module }: { module: UiModuleBlock } = $props();
|
|
||||||
|
|
||||||
let titleId = $derived(`${generatedId}-title`);
|
|
||||||
let ariaLabel = $derived(module.title ? undefined : module.label || module.id);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
class="module-card"
|
|
||||||
data-severity={module.severity || "neutral"}
|
|
||||||
data-model-id={module.id}
|
|
||||||
aria-labelledby={module.title ? titleId : undefined}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{#if module.title}
|
|
||||||
<h2 id={titleId}>{module.title}</h2>
|
|
||||||
{/if}
|
|
||||||
{#if module.value}
|
|
||||||
<strong>{module.value}</strong>
|
|
||||||
{/if}
|
|
||||||
{#if module.detail || module.label}
|
|
||||||
<p>{module.detail || module.label}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if module.icon}
|
|
||||||
<IconGlyph name={module.icon} size="lg" />
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.module-card {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: start;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 4.15rem;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background: rgba(6, 8, 7, 0.82);
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2,
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
line-height: 1;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
font-family: var(--ui-font-display);
|
|
||||||
font-size: clamp(1.45rem, 2vw, 2rem);
|
|
||||||
line-height: 0.82;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
color: var(--ui-color-muted);
|
|
||||||
font-size: 0.52rem;
|
|
||||||
line-height: 1.05;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-card[data-severity="ok"] :global(.icon-glyph) {
|
|
||||||
background: var(--ui-color-accent);
|
|
||||||
color: #050605;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-card[data-severity="warning"] {
|
|
||||||
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
|
|
||||||
color: var(--ui-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-card[data-severity="danger"] {
|
|
||||||
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 32%);
|
|
||||||
color: var(--ui-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-card[data-severity="stale"],
|
|
||||||
.module-card[data-severity="unavailable"] {
|
|
||||||
color: var(--ui-color-stale);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-card[data-severity="loading"] {
|
|
||||||
color: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
density = "dense",
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
density?: "compact" | "dense";
|
|
||||||
children?: Snippet;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="panel" data-density={density}>
|
|
||||||
{#if title}
|
|
||||||
<header class="panel__header">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
<div class="panel__body">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.panel {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background: rgba(3, 4, 3, 0.86);
|
|
||||||
box-shadow: var(--ui-shadow-hard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel::before,
|
|
||||||
.panel::after {
|
|
||||||
position: absolute;
|
|
||||||
width: 0.55rem;
|
|
||||||
height: 0.55rem;
|
|
||||||
border-color: var(--ui-color-line-strong);
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel::before {
|
|
||||||
top: 0.2rem;
|
|
||||||
right: 0.2rem;
|
|
||||||
border-top: 1px solid;
|
|
||||||
border-right: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel::after {
|
|
||||||
right: 0.2rem;
|
|
||||||
bottom: 0.2rem;
|
|
||||||
border-right: 1px solid;
|
|
||||||
border-bottom: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel__header {
|
|
||||||
padding: 0.42rem 0.55rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--ui-font-display);
|
|
||||||
font-size: clamp(1.45rem, 2.05vw, 1.95rem);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 0.9;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel__body {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.42rem 0.55rem 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel[data-density="compact"] .panel__body {
|
|
||||||
padding: var(--ui-space-2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { clampPercent } from "../format";
|
|
||||||
import type { UiSeverity } from "../types";
|
|
||||||
|
|
||||||
let {
|
|
||||||
value,
|
|
||||||
severity = "neutral",
|
|
||||||
label = "Progress",
|
|
||||||
}: {
|
|
||||||
value?: number;
|
|
||||||
severity?: UiSeverity;
|
|
||||||
label?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let progress = $derived(value === undefined ? null : clampPercent(value));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="progress-meter"
|
|
||||||
data-severity={severity}
|
|
||||||
role="meter"
|
|
||||||
aria-label={label}
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
aria-valuenow={progress ?? undefined}
|
|
||||||
data-empty={progress === null}
|
|
||||||
>
|
|
||||||
{#if progress !== null}
|
|
||||||
<span style={`--meter-progress: ${progress}%`}></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.progress-meter {
|
|
||||||
height: 0.4rem;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background: #030403;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-meter span {
|
|
||||||
display: block;
|
|
||||||
width: var(--meter-progress);
|
|
||||||
height: 100%;
|
|
||||||
background: var(--ui-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-meter[data-severity="warning"] span {
|
|
||||||
background: var(--ui-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-meter[data-severity="danger"] span {
|
|
||||||
background: var(--ui-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-meter[data-empty="true"] {
|
|
||||||
opacity: 0.46;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
intensity = "medium",
|
|
||||||
size = "md",
|
|
||||||
tone = "neutral",
|
|
||||||
}: {
|
|
||||||
intensity?: "soft" | "medium" | "hard";
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
tone?: "neutral" | "accent" | "warning";
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="scanline-field"
|
|
||||||
data-intensity={intensity}
|
|
||||||
data-size={size}
|
|
||||||
data-tone={tone}
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.scanline-field {
|
|
||||||
--scan-color: rgba(243, 244, 237, 0.12);
|
|
||||||
--scan-gap: 0.48rem;
|
|
||||||
--scan-weight: 1px;
|
|
||||||
display: block;
|
|
||||||
min-height: 4rem;
|
|
||||||
border: var(--ui-border);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 18% 24%, color-mix(in srgb, var(--scan-color), transparent 35%) 0 1px, transparent 2px),
|
|
||||||
radial-gradient(circle at 76% 62%, color-mix(in srgb, var(--scan-color), transparent 44%) 0 1px, transparent 2px),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
180deg,
|
|
||||||
transparent 0 calc(var(--scan-gap) - var(--scan-weight)),
|
|
||||||
var(--scan-color) calc(var(--scan-gap) - var(--scan-weight)) var(--scan-gap)
|
|
||||||
),
|
|
||||||
rgba(6, 7, 6, 0.78);
|
|
||||||
background-size: 3.1rem 3.1rem, 4.7rem 4.7rem, auto, auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline-field[data-intensity="soft"] {
|
|
||||||
--scan-color: rgba(243, 244, 237, 0.07);
|
|
||||||
--scan-gap: 0.62rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline-field[data-intensity="hard"] {
|
|
||||||
--scan-color: rgba(215, 255, 0, 0.2);
|
|
||||||
--scan-gap: 0.36rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline-field[data-size="sm"] {
|
|
||||||
min-height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline-field[data-size="lg"] {
|
|
||||||
min-height: 6.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline-field[data-tone="accent"] {
|
|
||||||
--scan-color: rgba(215, 255, 0, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline-field[data-tone="warning"] {
|
|
||||||
--scan-color: rgba(255, 176, 32, 0.16);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
orientation = "horizontal",
|
|
||||||
dense = false,
|
|
||||||
}: {
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
dense?: boolean;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="separator"
|
|
||||||
data-orientation={orientation}
|
|
||||||
data-dense={dense}
|
|
||||||
role="separator"
|
|
||||||
aria-orientation={orientation}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.separator {
|
|
||||||
background: var(--ui-color-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator[data-orientation="horizontal"] {
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
margin-block: var(--ui-space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator[data-orientation="vertical"] {
|
|
||||||
width: 1px;
|
|
||||||
min-height: 3rem;
|
|
||||||
align-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator[data-dense="true"] {
|
|
||||||
margin-block: var(--ui-space-1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UiServiceGroup } from "../types";
|
|
||||||
import ServicePanel from "./ServicePanel.svelte";
|
|
||||||
|
|
||||||
let { group }: { group: UiServiceGroup } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ServicePanel {group} />
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue