Compare commits
No commits in common. "main" and "codex/turbo-env-inputs" have entirely different histories.
main
...
codex/turb
119 changed files with 4544 additions and 5024 deletions
|
|
@ -1,85 +0,0 @@
|
||||||
name: Dimension Lab website
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: dimensionlab-website-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: docker
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Initialize submodules
|
|
||||||
run: |
|
|
||||||
git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/"
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
run: |
|
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
|
|
||||||
"$HOME/.bun/bin/bun" --version
|
|
||||||
|
|
||||||
- name: Check, test, and build
|
|
||||||
run: |
|
|
||||||
export BUN_INSTALL="$HOME/.bun"
|
|
||||||
export PATH="$BUN_INSTALL/bin:$PATH"
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run check
|
|
||||||
bun run test
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: ci
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
runs-on: deploy
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
if [ -d .git ]; then
|
|
||||||
git remote set-url origin git@git.dimensionlab.net:vince/dimensionlab-website.git
|
|
||||||
else
|
|
||||||
git init
|
|
||||||
git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git
|
|
||||||
fi
|
|
||||||
git fetch --force --prune --depth=1 origin "$GITHUB_SHA"
|
|
||||||
git checkout --force --detach "$GITHUB_SHA"
|
|
||||||
git clean -ffdx
|
|
||||||
|
|
||||||
- name: Initialize submodules
|
|
||||||
run: |
|
|
||||||
git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/"
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
- name: Verify Podman deployment socket
|
|
||||||
run: |
|
|
||||||
command -v podman
|
|
||||||
command -v systemctl
|
|
||||||
unit="$(timeout 15s podman inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')"
|
|
||||||
test "$unit" = "dimensionlab-website.service"
|
|
||||||
|
|
||||||
- name: Deploy production website
|
|
||||||
env:
|
|
||||||
DEPLOY_CONTAINER_CLI: podman
|
|
||||||
DEPLOY_EVENT_NAME: ${{ github.event_name }}
|
|
||||||
DEPLOY_REF: ${{ github.ref }}
|
|
||||||
DEPLOY_RESTART_STRATEGY: quadlet-container
|
|
||||||
DEPLOY_SHA: ${{ github.sha }}
|
|
||||||
run: scripts/deploy-dimensionlab-website.sh
|
|
||||||
4
.gitmodules
vendored
4
.gitmodules
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
[submodule "packages/ui"]
|
|
||||||
path = packages/ui
|
|
||||||
url = ssh://git@git.dimensionlab.net/vince/dimensionlab-ui.git
|
|
||||||
branch = main
|
|
||||||
33
README.md
33
README.md
|
|
@ -15,16 +15,14 @@ validated dashboard model state inside the web app.
|
||||||
persistence, Playwright e2e checks, and container build.
|
persistence, Playwright e2e checks, and container build.
|
||||||
- `packages/dashboard-model`: reusable dashboard schema, validation, and
|
- `packages/dashboard-model`: reusable dashboard schema, validation, and
|
||||||
generic model fixtures shared by apps and tooling.
|
generic model fixtures shared by apps and tooling.
|
||||||
- `packages/ui`: Git submodule for the reusable dashboard React components,
|
- `packages/ui`: reusable dashboard React components, design tokens,
|
||||||
design tokens, shadcn/radix primitives, generic fixtures, and Storybook.
|
shadcn/radix primitives, generic fixtures, and Storybook. Component source is
|
||||||
Component source is grouped under `foundation`, `frames`, `operations`, and
|
grouped under `foundation`, `frames`, `operations`, and `telemetry` domains.
|
||||||
`telemetry` domains.
|
|
||||||
- `docs/superpowers`: migration specs and execution plans used for this repo.
|
- `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
|
||||||
```
|
```
|
||||||
|
|
@ -129,7 +127,6 @@ The production build emits Vite client assets under `apps/web/dist/` and a Bun
|
||||||
server entry at `apps/web/build/index.js`. A minimal deployment flow is:
|
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
|
cd apps/web
|
||||||
|
|
@ -161,27 +158,3 @@ The env file must provide `AGENT_CONFIG_TOKEN`. Runtime defaults inside the
|
||||||
image set `HOST=0.0.0.0`, `PORT=3000`,
|
image set `HOST=0.0.0.0`, `PORT=3000`,
|
||||||
`DATABASE_URL=file:/data/dimensionlab.sqlite`, and
|
`DATABASE_URL=file:/data/dimensionlab.sqlite`, and
|
||||||
`DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle`.
|
`DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle`.
|
||||||
|
|
||||||
### Forgejo Actions Deployment
|
|
||||||
|
|
||||||
Merges to `main` run `.forgejo/workflows/dimensionlab-website.yml`. Pull
|
|
||||||
requests run check, test, and build only; the deploy job is guarded to run only
|
|
||||||
for `push` events on `refs/heads/main`.
|
|
||||||
|
|
||||||
The workflow uses two runner classes. Pull request CI runs on the containerized
|
|
||||||
`docker` runner. Production deployment runs on a separate host runner with the
|
|
||||||
`deploy:host` label so the guarded deploy script can use the user's rootless
|
|
||||||
`podman` and `systemctl --user` commands directly. The deploy job uses a
|
|
||||||
shell-only `git fetch` checkout so the host runner does not need a Node runtime
|
|
||||||
for checkout actions.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
runner:
|
|
||||||
labels:
|
|
||||||
- deploy:host
|
|
||||||
```
|
|
||||||
|
|
||||||
The deploy job also performs a host preflight against the
|
|
||||||
`dimensionlab-website.service` Podman label before it builds or restarts the
|
|
||||||
production container. Do not give the general pull request runner deployment
|
|
||||||
socket access; keep deploy privileges on the dedicated `deploy` runner.
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ RUN bunx turbo prune @dimensionlab/web --docker
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
COPY --from=pruner /repo/out/json/ ./
|
COPY --from=pruner /repo/out/json/ ./
|
||||||
RUN bun install --frozen-lockfile --ignore-scripts
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
"build": "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build",
|
"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",
|
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"test": "bun --bun vitest run",
|
"test": "vitest run",
|
||||||
"test:unit": "bun --bun vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:e2e": "env -u NO_COLOR playwright test",
|
"test:e2e": "env -u NO_COLOR playwright test",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "drizzle-kit check"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import { renderToString } from "react-dom/server";
|
import { renderToString } from "react-dom/server";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
import { AppStateView } from "./App";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
|
||||||
import {
|
|
||||||
AppStateView,
|
|
||||||
dashboardTileMatchKey,
|
|
||||||
dashboardHydrationTiles,
|
|
||||||
restoreDashboardTileSnapshots,
|
|
||||||
} from "./App";
|
|
||||||
|
|
||||||
describe("React app dashboard state view", () => {
|
describe("React app dashboard state view", () => {
|
||||||
test("renders loading dashboard state", () => {
|
test("renders loading dashboard state", () => {
|
||||||
|
|
@ -48,191 +41,4 @@ describe("React app dashboard state view", () => {
|
||||||
expect(html).toContain("Dark");
|
expect(html).toContain("Dark");
|
||||||
expect(html).toContain("Light");
|
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" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type {
|
|
||||||
DashboardDocument,
|
|
||||||
DashboardModule,
|
|
||||||
ServiceEntry,
|
|
||||||
StatusItem,
|
|
||||||
TelemetryCard,
|
|
||||||
} from "@dimensionlab/dashboard-model";
|
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
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 { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||||
import {
|
import {
|
||||||
DashboardFrame,
|
DashboardFrame,
|
||||||
|
|
@ -34,46 +9,21 @@ import {
|
||||||
resolveInitialUiTheme,
|
resolveInitialUiTheme,
|
||||||
type UiTheme,
|
type UiTheme,
|
||||||
type UiSeverity,
|
type UiSeverity,
|
||||||
type UiDashboardPreview,
|
|
||||||
} from "@dimensionlab/ui";
|
} from "@dimensionlab/ui";
|
||||||
|
|
||||||
type DashboardTileResponse =
|
const loadingDashboardState: DashboardRuntimeState = {
|
||||||
| {
|
state: "loading",
|
||||||
state: "ready";
|
title: "Loading Dashboard",
|
||||||
tile: DashboardTileReference;
|
subtitle: "Fetching active model",
|
||||||
item: DashboardModule | ServiceEntry | StatusItem | TelemetryCard;
|
message: "Waiting for the active dashboard document.",
|
||||||
}
|
|
||||||
| {
|
|
||||||
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({
|
export function AppStateView({
|
||||||
dashboard,
|
dashboard,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
theme,
|
theme,
|
||||||
hydratingItemIds,
|
|
||||||
}: {
|
}: {
|
||||||
dashboard: DashboardRuntimeState;
|
dashboard: DashboardRuntimeState;
|
||||||
hydratingItemIds?: ReadonlySet<string>;
|
|
||||||
onThemeChange?: (theme: UiTheme) => void;
|
onThemeChange?: (theme: UiTheme) => void;
|
||||||
theme?: UiTheme;
|
theme?: UiTheme;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -83,14 +33,9 @@ export function AppStateView({
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (dashboard.state === "ready") {
|
if (dashboard.state === "ready") {
|
||||||
const uiDashboard = markHydratingItems(
|
|
||||||
dashboardDocumentToUiDashboard(dashboard.document),
|
|
||||||
hydratingItemIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardFrame
|
<DashboardFrame
|
||||||
dashboard={uiDashboard}
|
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
||||||
actions={themeToggle}
|
actions={themeToggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -144,10 +89,7 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [dashboard, setDashboard] =
|
const [dashboard, setDashboard] =
|
||||||
useState<DashboardRuntimeState | null>(null);
|
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||||
const [hydratingItemIds, setHydratingItemIds] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const [theme, setTheme] = useState<UiTheme>(() => {
|
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||||
if (typeof window === "undefined") return "dark";
|
if (typeof window === "undefined") return "dark";
|
||||||
|
|
||||||
|
|
@ -155,8 +97,6 @@ export default function App() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dashboard) return;
|
|
||||||
|
|
||||||
const metadata = resolveDocumentMetadata(dashboard);
|
const metadata = resolveDocumentMetadata(dashboard);
|
||||||
document.title = metadata.title;
|
document.title = metadata.title;
|
||||||
|
|
||||||
|
|
@ -179,108 +119,17 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
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() {
|
async function loadDashboard() {
|
||||||
if (cancelled || refreshPaused()) return;
|
const response = await fetch("/api/dashboard");
|
||||||
|
|
||||||
clearRefreshTimer();
|
|
||||||
const shellSignal = requestAborter.beginShellRun();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/dashboard", { signal: shellSignal });
|
|
||||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||||
|
|
||||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
if (cancelled) return;
|
||||||
refreshDelay.recordSuccess();
|
setDashboard(nextDashboard);
|
||||||
performanceMarks.markShellLoad();
|
|
||||||
const restored = restoreDashboardTileSnapshots(
|
|
||||||
nextDashboard,
|
|
||||||
nextDashboard.state === "ready"
|
|
||||||
? tileSnapshotStore.restore({
|
|
||||||
currentRevisionId: nextDashboard.currentRevisionId,
|
|
||||||
schemaVersion: nextDashboard.schemaVersion,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
setDashboard(restored.dashboard);
|
|
||||||
|
|
||||||
const currentRun = ++hydrationRun;
|
if (refreshTimer) {
|
||||||
if (
|
window.clearInterval(refreshTimer);
|
||||||
restored.dashboard.state === "ready" &&
|
refreshTimer = undefined;
|
||||||
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 =
|
const refreshIntervalSeconds =
|
||||||
|
|
@ -288,295 +137,29 @@ export default function App() {
|
||||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (refreshIntervalSeconds && !refreshPaused()) {
|
if (refreshIntervalSeconds) {
|
||||||
lastRefreshIntervalMs = refreshIntervalSeconds * 1000;
|
refreshTimer = window.setInterval(
|
||||||
scheduleNextRefresh(lastRefreshIntervalMs);
|
() => void loadDashboard(),
|
||||||
}
|
refreshIntervalSeconds * 1000,
|
||||||
} 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();
|
void loadDashboard();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearRefreshTimer();
|
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||||
unsubscribeTileEvents?.();
|
|
||||||
requestAborter.abortActiveRequests();
|
|
||||||
detachRefreshLifecycle();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return dashboard ? (
|
return (
|
||||||
<AppStateView
|
<AppStateView
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
hydratingItemIds={hydratingItemIds}
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onThemeChange={setTheme}
|
onThemeChange={setTheme}
|
||||||
/>
|
/>
|
||||||
) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
|
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
|
||||||
|
|
@ -598,278 +181,3 @@ function getThemeStorage(): Storage | undefined {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTileSnapshotStorage(): Storage | undefined {
|
|
||||||
try {
|
|
||||||
return window.localStorage;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardIdleWindow = Window & {
|
|
||||||
cancelIdleCallback?: (handle: number) => void;
|
|
||||||
requestIdleCallback?: (
|
|
||||||
callback: () => void,
|
|
||||||
options?: { timeout?: number },
|
|
||||||
) => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDashboardIdleCallbacks() {
|
|
||||||
const idleWindow = window as DashboardIdleWindow;
|
|
||||||
return {
|
|
||||||
cancelIdleCallback: idleWindow.cancelIdleCallback?.bind(idleWindow),
|
|
||||||
requestIdleCallback: idleWindow.requestIdleCallback?.bind(idleWindow),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDashboardIntersectionObserverFactory():
|
|
||||||
| DashboardIntersectionObserverFactory
|
|
||||||
| undefined {
|
|
||||||
if (typeof window.IntersectionObserver === "undefined") return undefined;
|
|
||||||
|
|
||||||
return (callback) =>
|
|
||||||
new window.IntersectionObserver((entries) => {
|
|
||||||
callback(entries);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForDashboardRenderFrame(signal: AbortSignal): Promise<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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,633 +0,0 @@
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import {
|
|
||||||
attachDashboardRefreshLifecycle,
|
|
||||||
collectVisibleDashboardModelIds,
|
|
||||||
createDashboardPerformanceMarks,
|
|
||||||
createDashboardRefreshDelay,
|
|
||||||
dashboardPerformanceMarks,
|
|
||||||
createDashboardTileSnapshotStore,
|
|
||||||
createDashboardTileBackoff,
|
|
||||||
createDashboardRequestAborter,
|
|
||||||
runDashboardHydrationQueue,
|
|
||||||
runDashboardHydrationBatchQueue,
|
|
||||||
runViewportAwareDashboardHydrationQueue,
|
|
||||||
shouldPauseDashboardRefresh,
|
|
||||||
splitDashboardHydrationItemsByVisibility,
|
|
||||||
subscribeToDashboardTileEvents,
|
|
||||||
waitForDashboardHydrationIdle,
|
|
||||||
type DashboardIntersectionEntry,
|
|
||||||
} from "./dashboard-refresh";
|
|
||||||
|
|
||||||
describe("dashboard refresh lifecycle", () => {
|
|
||||||
test("pauses refreshes when the document is hidden or the browser is offline", () => {
|
|
||||||
expect(
|
|
||||||
shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aborts shell and tile requests when a new shell run starts", () => {
|
|
||||||
const aborter = createDashboardRequestAborter();
|
|
||||||
const shellSignal = aborter.beginShellRun();
|
|
||||||
const tileSignal = aborter.beginTileRun();
|
|
||||||
|
|
||||||
const nextShellSignal = aborter.beginShellRun();
|
|
||||||
|
|
||||||
expect(shellSignal.aborted).toBe(true);
|
|
||||||
expect(tileSignal.aborted).toBe(true);
|
|
||||||
expect(nextShellSignal.aborted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aborts active requests when refreshes are paused", () => {
|
|
||||||
const aborter = createDashboardRequestAborter();
|
|
||||||
const shellSignal = aborter.beginShellRun();
|
|
||||||
const tileSignal = aborter.beginTileRun();
|
|
||||||
|
|
||||||
aborter.abortActiveRequests();
|
|
||||||
|
|
||||||
expect(shellSignal.aborted).toBe(true);
|
|
||||||
expect(tileSignal.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => {
|
|
||||||
const documentTarget = new EventTarget();
|
|
||||||
const windowTarget = new EventTarget();
|
|
||||||
const calls: string[] = [];
|
|
||||||
let paused = true;
|
|
||||||
|
|
||||||
const detach = attachDashboardRefreshLifecycle({
|
|
||||||
documentTarget,
|
|
||||||
windowTarget,
|
|
||||||
loadDashboard: () => {
|
|
||||||
calls.push("load");
|
|
||||||
},
|
|
||||||
pauseRefreshes: () => {
|
|
||||||
calls.push("pause");
|
|
||||||
},
|
|
||||||
refreshPaused: () => paused,
|
|
||||||
});
|
|
||||||
|
|
||||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
windowTarget.dispatchEvent(new Event("offline"));
|
|
||||||
windowTarget.dispatchEvent(new Event("pagehide"));
|
|
||||||
|
|
||||||
paused = false;
|
|
||||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
windowTarget.dispatchEvent(new Event("online"));
|
|
||||||
|
|
||||||
detach();
|
|
||||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
windowTarget.dispatchEvent(new Event("online"));
|
|
||||||
|
|
||||||
expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("limits tile hydration concurrency", async () => {
|
|
||||||
let active = 0;
|
|
||||||
let maxActive = 0;
|
|
||||||
const started: number[] = [];
|
|
||||||
const releases = new Map<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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,886 +0,0 @@
|
||||||
export interface DashboardRefreshPauseState {
|
|
||||||
visibilityState: DocumentVisibilityState;
|
|
||||||
online: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DashboardTileReference =
|
|
||||||
| { kind: "telemetry"; id: string }
|
|
||||||
| { kind: "service"; groupId: string; id: string }
|
|
||||||
| { kind: "module"; id: string }
|
|
||||||
| { kind: "status"; stripId: string; id: string };
|
|
||||||
|
|
||||||
type SnapshotMetricValue = Record<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;
|
|
||||||
}
|
|
||||||
|
|
@ -128,6 +128,7 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,14 @@ 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({
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ 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");
|
||||||
});
|
});
|
||||||
|
|
@ -135,5 +138,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ export interface DashboardRuntimeReady {
|
||||||
document: DashboardDocument;
|
document: DashboardDocument;
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
currentRevisionId: string;
|
currentRevisionId: string;
|
||||||
liveDatasourceHydration?: {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardRuntimeEmpty {
|
export interface DashboardRuntimeEmpty {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
DASHBOARD_SCHEMA_VERSION,
|
DASHBOARD_SCHEMA_VERSION,
|
||||||
type DashboardDocument,
|
type DashboardDocument,
|
||||||
} from "@dimensionlab/dashboard-model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
import { resolveDashboardDatasources, resolveDashboardTile } from ".";
|
import { resolveDashboardDatasources } 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,100 +124,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -10,38 +10,8 @@ import type {
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "@dimensionlab/dashboard-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;
|
||||||
|
|
@ -76,90 +46,16 @@ 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];
|
||||||
|
|
@ -171,12 +67,8 @@ interface PrometheusMatrixResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
||||||
const fetch = options.fetch || globalThis.fetch;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch,
|
fetch: options.fetch || globalThis.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 ||
|
||||||
|
|
@ -187,68 +79,6 @@ function datasourceContext(options: DatasourceResolutionOptions): DatasourceCont
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function datasourceFetchIdentity(fetch: DatasourceFetch): number {
|
|
||||||
const existing = datasourceFetchIdentities.get(fetch);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const next = nextDatasourceFetchIdentity;
|
|
||||||
nextDatasourceFetchIdentity += 1;
|
|
||||||
datasourceFetchIdentities.set(fetch, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceGroupsSnapshot(
|
|
||||||
document: DashboardDocument,
|
|
||||||
context: DatasourceContext,
|
|
||||||
): Promise<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,
|
||||||
|
|
@ -560,66 +390,6 @@ 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;
|
||||||
|
|
@ -903,17 +673,3 @@ 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}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { spawnSync } from "node:child_process";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
|
@ -44,8 +42,6 @@ describe("workspace boundaries", () => {
|
||||||
"turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e",
|
"turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e",
|
||||||
);
|
);
|
||||||
expect(webPackage.scripts).not.toHaveProperty("test:qa");
|
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.tasks).not.toHaveProperty("test:qa");
|
||||||
expect(turboConfig.globalDependencies).toEqual(
|
expect(turboConfig.globalDependencies).toEqual(
|
||||||
expect.arrayContaining(["bun.lock", "tsconfig.base.json"]),
|
expect.arrayContaining(["bun.lock", "tsconfig.base.json"]),
|
||||||
|
|
@ -199,7 +195,7 @@ describe("workspace boundaries", () => {
|
||||||
const sourceCopy = "COPY --from=pruner /repo/out/full/ ./";
|
const sourceCopy = "COPY --from=pruner /repo/out/full/ ./";
|
||||||
const tsconfigCopy =
|
const tsconfigCopy =
|
||||||
"COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./";
|
"COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./";
|
||||||
const installCommand = "RUN bun install --frozen-lockfile --ignore-scripts";
|
const installCommand = "RUN bun install --frozen-lockfile";
|
||||||
|
|
||||||
expect(containerfile).toContain("AS pruner");
|
expect(containerfile).toContain("AS pruner");
|
||||||
expect(containerfile).toContain(pruneCommand);
|
expect(containerfile).toContain(pruneCommand);
|
||||||
|
|
@ -257,186 +253,6 @@ describe("workspace boundaries", () => {
|
||||||
"rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build",
|
"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 =
|
type WorkspacePackageExport =
|
||||||
|
|
@ -453,126 +269,3 @@ function expectPackageExport(
|
||||||
): void {
|
): void {
|
||||||
expect(actual).toMatchObject(expected);
|
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
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
||||||
import { handleRequest } from "./index";
|
|
||||||
|
|
||||||
describe("server request routing", () => {
|
|
||||||
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
consoleInfo.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("routes dashboard tile batch requests", async () => {
|
|
||||||
const response = await handleRequest(
|
|
||||||
new Request("https://example.test/api/dashboard/tiles", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
tiles: [
|
|
||||||
{
|
|
||||||
kind: "status",
|
|
||||||
stripId: "footer-status",
|
|
||||||
id: "auto-refresh",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
await expect(response.json()).resolves.toMatchObject({
|
|
||||||
state: "ready",
|
|
||||||
tiles: [
|
|
||||||
{
|
|
||||||
state: "ready",
|
|
||||||
tile: {
|
|
||||||
kind: "status",
|
|
||||||
stripId: "footer-status",
|
|
||||||
id: "auto-refresh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects non-post dashboard tile batch requests", async () => {
|
|
||||||
const response = await handleRequest(
|
|
||||||
new Request("https://example.test/api/dashboard/tiles", {
|
|
||||||
method: "GET",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(405);
|
|
||||||
expect(response.headers.get("allow")).toBe("POST");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("routes dashboard event stream requests", async () => {
|
|
||||||
const response = await handleRequest(
|
|
||||||
new Request("https://example.test/api/dashboard/events", {
|
|
||||||
method: "GET",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toBe(
|
|
||||||
"text/event-stream; charset=utf-8",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects non-get dashboard event stream requests", async () => {
|
|
||||||
const response = await handleRequest(
|
|
||||||
new Request("https://example.test/api/dashboard/events", {
|
|
||||||
method: "POST",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(405);
|
|
||||||
expect(response.headers.get("allow")).toBe("GET");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { extname, normalize } from "node:path";
|
import { extname, normalize } from "node:path";
|
||||||
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
||||||
import {
|
import { handleDashboardRoute } from "./routes/dashboard";
|
||||||
handleDashboardEventsRoute,
|
|
||||||
handleDashboardRoute,
|
|
||||||
handleDashboardTileRoute,
|
|
||||||
handleDashboardTilesRoute,
|
|
||||||
} from "./routes/dashboard";
|
|
||||||
|
|
||||||
const host = process.env.HOST || "0.0.0.0";
|
const host = process.env.HOST || "0.0.0.0";
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
|
@ -28,21 +23,6 @@ export async function handleRequest(request: Request): Promise<Response> {
|
||||||
return handleDashboardRoute();
|
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 (url.pathname === "/api/agent/dashboard") {
|
||||||
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
||||||
return handleAgentDashboardRoute(request);
|
return handleAgentDashboardRoute(request);
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,11 @@
|
||||||
import { afterAll, afterEach, describe, expect, test, vi } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import {
|
import { loadDashboardResponse } from "./dashboard";
|
||||||
createDashboardTileCache,
|
|
||||||
dashboardTileCacheKey,
|
|
||||||
handleDashboardEventsRoute,
|
|
||||||
handleDashboardTilesRoute,
|
|
||||||
handleDashboardTileRoute,
|
|
||||||
loadDashboardResponse,
|
|
||||||
loadDashboardTilesResponse,
|
|
||||||
loadDashboardTileResponse,
|
|
||||||
type DashboardTileResolutionLogEvent,
|
|
||||||
} from "./dashboard";
|
|
||||||
|
|
||||||
describe("dashboard API route", () => {
|
describe("dashboard API route", () => {
|
||||||
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
test("returns ready dashboard runtime state from the existing model loader", async () => {
|
||||||
|
|
||||||
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({
|
const response = await loadDashboardResponse({
|
||||||
|
disableLiveDatasources: true,
|
||||||
refreshSeedDocument: true,
|
refreshSeedDocument: true,
|
||||||
seedIfEmpty: true,
|
seedIfEmpty: true,
|
||||||
});
|
});
|
||||||
|
|
@ -38,762 +15,5 @@ describe("dashboard API route", () => {
|
||||||
expect(response.document.metadata.title).toBe(
|
expect(response.document.metadata.title).toBe(
|
||||||
dimensionLabDashboardFixture.metadata.title,
|
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}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,138 +1,21 @@
|
||||||
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
|
|
||||||
import {
|
import {
|
||||||
loadDashboardRuntime,
|
loadDashboardRuntime,
|
||||||
type DashboardRuntimeOptions,
|
type DashboardRuntimeOptions,
|
||||||
type DashboardRuntimeState,
|
type DashboardRuntimeState,
|
||||||
} from "$lib/server/dashboard";
|
} from "$lib/server/dashboard";
|
||||||
import {
|
import { resolveDashboardDatasources } from "$lib/server/datasources";
|
||||||
resolveDashboardDatasources,
|
|
||||||
resolveDashboardTile,
|
|
||||||
type DashboardTileReference,
|
|
||||||
type DashboardTileResolution,
|
|
||||||
type DatasourceResolutionOptions,
|
|
||||||
} from "$lib/server/datasources";
|
|
||||||
|
|
||||||
export interface LoadDashboardResponseOptions
|
export interface LoadDashboardResponseOptions
|
||||||
extends Pick<
|
extends Pick<
|
||||||
DashboardRuntimeOptions,
|
DashboardRuntimeOptions,
|
||||||
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
||||||
>,
|
> {
|
||||||
DatasourceResolutionOptions {
|
|
||||||
disableLiveDatasources?: boolean;
|
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(
|
export async function loadDashboardResponse(
|
||||||
options: LoadDashboardResponseOptions = {},
|
options: LoadDashboardResponseOptions = {},
|
||||||
): Promise<DashboardRuntimeState> {
|
): Promise<DashboardRuntimeState> {
|
||||||
const liveHydrationEnabled =
|
|
||||||
!options.disableLiveDatasources &&
|
|
||||||
process.env.DISABLE_LIVE_DATASOURCES !== "1";
|
|
||||||
const dashboard = loadDashboardRuntime(undefined, {
|
const dashboard = loadDashboardRuntime(undefined, {
|
||||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||||
|
|
@ -143,467 +26,16 @@ export async function loadDashboardResponse(
|
||||||
return dashboard;
|
return dashboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
|
||||||
!options.hydrateLiveDatasources ||
|
return dashboard;
|
||||||
options.disableLiveDatasources ||
|
|
||||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...dashboard,
|
|
||||||
liveDatasourceHydration: {
|
|
||||||
enabled: liveHydrationEnabled,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dashboard,
|
...dashboard,
|
||||||
document: await resolveDashboardDatasources(dashboard.document, options),
|
document: await resolveDashboardDatasources(dashboard.document),
|
||||||
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> {
|
export async function handleDashboardRoute(): Promise<Response> {
|
||||||
return Response.json(await loadDashboardResponse());
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const linkedServiceIds = [
|
||||||
"open-webui",
|
"open-webui",
|
||||||
"comfyui",
|
"comfyui",
|
||||||
"models",
|
"models",
|
||||||
|
"prompt-registry",
|
||||||
"adminer",
|
"adminer",
|
||||||
"assistant",
|
"assistant",
|
||||||
"suna",
|
"suna",
|
||||||
|
|
@ -103,7 +104,7 @@ test.describe("dashboard page QA gate", () => {
|
||||||
expect(metrics.runtimeBottom).toBeLessThanOrEqual(956);
|
expect(metrics.runtimeBottom).toBeLessThanOrEqual(956);
|
||||||
expect(metrics.footerBottom).toBeLessThanOrEqual(956);
|
expect(metrics.footerBottom).toBeLessThanOrEqual(956);
|
||||||
expect(metrics.telemetryCardCount).toBe(16);
|
expect(metrics.telemetryCardCount).toBe(16);
|
||||||
expect(metrics.serviceRowCount).toBe(27);
|
expect(metrics.serviceRowCount).toBe(28);
|
||||||
expect(metrics.footerCellCount).toBe(5);
|
expect(metrics.footerCellCount).toBe(5);
|
||||||
expect(metrics.clippedItems).toEqual([]);
|
expect(metrics.clippedItems).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 451 KiB After Width: | Height: | Size: 454 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 309 KiB |
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit a7a472083555152b8e1a2dc018d3be5b3b10d60b
|
|
||||||
15
packages/ui/.storybook/main.ts
Normal file
15
packages/ui/.storybook/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
|
||||||
|
addons: [
|
||||||
|
"@storybook/addon-a11y",
|
||||||
|
"@storybook/addon-vitest",
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: "@storybook/react-vite",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
51
packages/ui/.storybook/preview.ts
Normal file
51
packages/ui/.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import "../src/styles.css";
|
||||||
|
import type { Preview } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
decorators: [
|
||||||
|
(Story, context) => {
|
||||||
|
const theme = context.globals.theme === "light" ? "light" : "dark";
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.documentElement.setAttribute("data-ui-theme", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Story();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
globalTypes: {
|
||||||
|
theme: {
|
||||||
|
description: "Dashboard component theme",
|
||||||
|
defaultValue: "dark",
|
||||||
|
toolbar: {
|
||||||
|
title: "Theme",
|
||||||
|
icon: "circlehollow",
|
||||||
|
items: [
|
||||||
|
{ value: "dark", title: "Dark" },
|
||||||
|
{ value: "light", title: "Light" },
|
||||||
|
],
|
||||||
|
dynamicTitle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
backgrounds: {
|
||||||
|
default: "canvas",
|
||||||
|
values: [
|
||||||
|
{ name: "canvas", value: "#0b0f0d" },
|
||||||
|
{ name: "raised", value: "#151d18" },
|
||||||
|
{ name: "light canvas", value: "#eef2e7" },
|
||||||
|
{ name: "light raised", value: "#f1f5ea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout: "fullscreen",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
25
packages/ui/components.json
Normal file
25
packages/ui/components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "src/components",
|
||||||
|
"utils": "src/utils",
|
||||||
|
"ui": "src/primitives",
|
||||||
|
"lib": "src",
|
||||||
|
"hooks": "src/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
79
packages/ui/package.json
Normal file
79
packages/ui/package.json
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
{
|
||||||
|
"name": "@dimensionlab/ui",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": ["*.css", "**/*.css"],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./foundation": {
|
||||||
|
"types": "./dist/components/foundation/index.d.ts",
|
||||||
|
"development": "./src/components/foundation/index.ts",
|
||||||
|
"default": "./dist/components/foundation/index.js"
|
||||||
|
},
|
||||||
|
"./frames": {
|
||||||
|
"types": "./dist/components/frames/index.d.ts",
|
||||||
|
"development": "./src/components/frames/index.ts",
|
||||||
|
"default": "./dist/components/frames/index.js"
|
||||||
|
},
|
||||||
|
"./operations": {
|
||||||
|
"types": "./dist/components/operations/index.d.ts",
|
||||||
|
"development": "./src/components/operations/index.ts",
|
||||||
|
"default": "./dist/components/operations/index.js"
|
||||||
|
},
|
||||||
|
"./telemetry": {
|
||||||
|
"types": "./dist/components/telemetry/index.d.ts",
|
||||||
|
"development": "./src/components/telemetry/index.ts",
|
||||||
|
"default": "./dist/components/telemetry/index.js"
|
||||||
|
},
|
||||||
|
"./styles.css": {
|
||||||
|
"development": "./src/styles.css",
|
||||||
|
"default": "./dist/styles.css"
|
||||||
|
},
|
||||||
|
"./tokens.css": {
|
||||||
|
"development": "./src/tokens.css",
|
||||||
|
"default": "./dist/tokens.css"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/components && cp src/styles.css dist/styles.css && cp src/tokens.css dist/tokens.css && cp src/components/styles.css dist/components/styles.css",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"radix-ui": "^1.6.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"uplot": "^1.6.32"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@storybook/addon-a11y": "^10.4.6",
|
||||||
|
"@storybook/addon-vitest": "^10.4.6",
|
||||||
|
"@storybook/react-vite": "^10.4.6",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"bun-types": "^1.3.14",
|
||||||
|
"storybook": "^10.4.6",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/ui/src/components/foundation/Badge.tsx
Normal file
11
packages/ui/src/components/foundation/Badge.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { UiSeverity } from "../../types";
|
||||||
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
label: string;
|
||||||
|
severity?: UiSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ label, severity = "neutral" }: BadgeProps) {
|
||||||
|
return <StatusBadge label={label} severity={severity} />;
|
||||||
|
}
|
||||||
39
packages/ui/src/components/foundation/Button.tsx
Normal file
39
packages/ui/src/components/foundation/Button.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
import { IconGlyph } from "./IconGlyph";
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
|
||||||
|
label: string;
|
||||||
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
size?: "default" | "compact";
|
||||||
|
icon?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
label,
|
||||||
|
variant = "primary",
|
||||||
|
size = "default",
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
type = "button",
|
||||||
|
className = "",
|
||||||
|
...buttonProps
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...buttonProps}
|
||||||
|
className={className ? `ui-button ${className} ` : "ui-button "}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-loading={loading}
|
||||||
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
>
|
||||||
|
{icon ? <IconGlyph name={icon} size="sm" /> : null}
|
||||||
|
<span>{loading ? "Loading" : label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
packages/ui/src/components/foundation/IconButton.tsx
Normal file
33
packages/ui/src/components/foundation/IconButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
import { IconGlyph } from "./IconGlyph";
|
||||||
|
|
||||||
|
export interface IconButtonProps
|
||||||
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
active = false,
|
||||||
|
disabled = false,
|
||||||
|
type = "button",
|
||||||
|
className = "",
|
||||||
|
...buttonProps
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...buttonProps}
|
||||||
|
className={className ? `icon-button ${className} ` : "icon-button "}
|
||||||
|
aria-label={label}
|
||||||
|
data-active={active}
|
||||||
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
>
|
||||||
|
<IconGlyph name={icon} size="sm" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
packages/ui/src/components/foundation/IconGlyph.tsx
Normal file
21
packages/ui/src/components/foundation/IconGlyph.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
export interface IconGlyphProps {
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconGlyph({ name, label, size = "md" }: IconGlyphProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="icon-glyph"
|
||||||
|
data-size={size}
|
||||||
|
data-icon-name={name}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : "true"}
|
||||||
|
>
|
||||||
|
{name ? <Icon icon={name} /> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
packages/ui/src/components/foundation/ProgressMeter.tsx
Normal file
34
packages/ui/src/components/foundation/ProgressMeter.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { clampPercent } from "../../format";
|
||||||
|
import type { UiSeverity } from "../../types";
|
||||||
|
|
||||||
|
export interface ProgressMeterProps {
|
||||||
|
value?: number;
|
||||||
|
severity?: UiSeverity;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressMeter({
|
||||||
|
value,
|
||||||
|
severity = "neutral",
|
||||||
|
label = "Progress",
|
||||||
|
}: ProgressMeterProps) {
|
||||||
|
const progress = value === undefined ? null : clampPercent(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="progress-meter"
|
||||||
|
data-severity={severity}
|
||||||
|
role="meter"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={progress ?? undefined}
|
||||||
|
data-empty={progress === null}
|
||||||
|
>
|
||||||
|
{progress !== null ? (
|
||||||
|
<span style={{ "--meter-progress": `${progress}%` } as CSSProperties} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
packages/ui/src/components/foundation/Separator.tsx
Normal file
19
packages/ui/src/components/foundation/Separator.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export interface SeparatorProps {
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
dense?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Separator({
|
||||||
|
orientation = "horizontal",
|
||||||
|
dense = false,
|
||||||
|
}: SeparatorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="separator"
|
||||||
|
data-orientation={orientation}
|
||||||
|
data-dense={dense}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={orientation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
packages/ui/src/components/foundation/StatusBadge.tsx
Normal file
14
packages/ui/src/components/foundation/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { UiSeverity } from "../../types";
|
||||||
|
|
||||||
|
export interface StatusBadgeProps {
|
||||||
|
label: string;
|
||||||
|
severity?: UiSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ label, severity = "neutral" }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className="status-badge" data-severity={severity}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
packages/ui/src/components/foundation/ThemeToggle.tsx
Normal file
36
packages/ui/src/components/foundation/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { UiTheme } from "../../theme";
|
||||||
|
import { getNextUiTheme } from "../../theme";
|
||||||
|
import { IconGlyph } from "./IconGlyph";
|
||||||
|
|
||||||
|
export interface ThemeToggleProps {
|
||||||
|
theme: UiTheme;
|
||||||
|
onThemeChange: (theme: UiTheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ theme, onThemeChange }: ThemeToggleProps) {
|
||||||
|
const nextTheme = getNextUiTheme(theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label="Light theme"
|
||||||
|
aria-pressed={theme === "light"}
|
||||||
|
className="theme-toggle"
|
||||||
|
data-ui-theme-current={theme}
|
||||||
|
data-ui-theme-toggle="true"
|
||||||
|
onClick={() => onThemeChange(nextTheme)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="theme-toggle__label">Theme</span>
|
||||||
|
<span className="theme-toggle__switch" aria-hidden="true">
|
||||||
|
<span className="theme-toggle__cell" data-active={theme === "dark"}>
|
||||||
|
<IconGlyph name="mdi:weather-night" size="sm" />
|
||||||
|
<span>Dark</span>
|
||||||
|
</span>
|
||||||
|
<span className="theme-toggle__cell" data-active={theme === "light"}>
|
||||||
|
<IconGlyph name="mdi:white-balance-sunny" size="sm" />
|
||||||
|
<span>Light</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
packages/ui/src/components/foundation/index.ts
Normal file
8
packages/ui/src/components/foundation/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export * from "./Badge";
|
||||||
|
export * from "./Button";
|
||||||
|
export * from "./IconButton";
|
||||||
|
export * from "./IconGlyph";
|
||||||
|
export * from "./ProgressMeter";
|
||||||
|
export * from "./Separator";
|
||||||
|
export * from "./StatusBadge";
|
||||||
|
export * from "./ThemeToggle";
|
||||||
26
packages/ui/src/components/frames/CornerBracketFrame.tsx
Normal file
26
packages/ui/src/components/frames/CornerBracketFrame.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
export interface CornerBracketFrameProps {
|
||||||
|
density?: "regular" | "tight";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
tone?: "neutral" | "accent" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CornerBracketFrame({
|
||||||
|
density = "regular",
|
||||||
|
size = "md",
|
||||||
|
tone = "neutral",
|
||||||
|
}: CornerBracketFrameProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="corner-bracket-frame"
|
||||||
|
data-density={density}
|
||||||
|
data-size={size}
|
||||||
|
data-tone={tone}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span data-corner="top-left" />
|
||||||
|
<span data-corner="top-right" />
|
||||||
|
<span data-corner="bottom-left" />
|
||||||
|
<span data-corner="bottom-right" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
packages/ui/src/components/frames/DashboardFrame.tsx
Normal file
59
packages/ui/src/components/frames/DashboardFrame.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { UiDashboardPreview } from "../../types";
|
||||||
|
import { ModuleCard } from "./ModuleCard";
|
||||||
|
import { ServicePanel } from "../operations/ServicePanel";
|
||||||
|
import { StatusStrip } from "../operations/StatusStrip";
|
||||||
|
import { TelemetryGrid } from "../telemetry/TelemetryGrid";
|
||||||
|
|
||||||
|
export interface DashboardFrameProps {
|
||||||
|
actions?: ReactNode;
|
||||||
|
dashboard: UiDashboardPreview;
|
||||||
|
titleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardFrame({
|
||||||
|
actions,
|
||||||
|
dashboard,
|
||||||
|
titleId,
|
||||||
|
}: DashboardFrameProps) {
|
||||||
|
const generatedTitleId = useId();
|
||||||
|
const resolvedTitleId = titleId || `${generatedTitleId}-title`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="dashboard-frame" aria-labelledby={resolvedTitleId}>
|
||||||
|
<header className="dashboard-frame__header console-header">
|
||||||
|
<div className="dashboard-frame__title">
|
||||||
|
<div className="dashboard-frame__title-copy">
|
||||||
|
{dashboard.eyebrow ? <p>{dashboard.eyebrow}</p> : null}
|
||||||
|
<h1 id={resolvedTitleId}>{dashboard.title}</h1>
|
||||||
|
{dashboard.subtitle ? <span>{dashboard.subtitle}</span> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="dashboard-frame__actions">{actions}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{dashboard.modules.length ? (
|
||||||
|
<div className="dashboard-frame__modules">
|
||||||
|
{dashboard.modules.map((module) => (
|
||||||
|
<ModuleCard key={module.id} module={module} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TelemetryGrid cards={dashboard.telemetry} />
|
||||||
|
|
||||||
|
<section className="dashboard-frame__panels" aria-label="Service groups">
|
||||||
|
{dashboard.serviceGroups.map((group) => (
|
||||||
|
<ServicePanel key={group.id} group={group} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<StatusStrip
|
||||||
|
id={dashboard.statusStripId}
|
||||||
|
items={dashboard.statusItems}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
packages/ui/src/components/frames/DashboardHeader.tsx
Normal file
30
packages/ui/src/components/frames/DashboardHeader.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { UiModuleBlock } from "../../types";
|
||||||
|
import { ModuleCard } from "./ModuleCard";
|
||||||
|
|
||||||
|
export interface DashboardHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
module?: UiModuleBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
eyebrow,
|
||||||
|
module,
|
||||||
|
}: DashboardHeaderProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="dashboard-header" aria-labelledby={titleId}>
|
||||||
|
<div>
|
||||||
|
{eyebrow ? <p>{eyebrow}</p> : null}
|
||||||
|
<h1 id={titleId}>{title}</h1>
|
||||||
|
{subtitle ? <span>{subtitle}</span> : null}
|
||||||
|
</div>
|
||||||
|
{module ? <ModuleCard module={module} /> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/ui/src/components/frames/DiagonalStripeField.tsx
Normal file
24
packages/ui/src/components/frames/DiagonalStripeField.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export interface DiagonalStripeFieldProps {
|
||||||
|
density?: "open" | "regular" | "tight";
|
||||||
|
direction?: "forward" | "backward";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
tone?: "neutral" | "accent" | "warning" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiagonalStripeField({
|
||||||
|
density = "regular",
|
||||||
|
direction = "forward",
|
||||||
|
size = "md",
|
||||||
|
tone = "accent",
|
||||||
|
}: DiagonalStripeFieldProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="diagonal-stripe-field"
|
||||||
|
data-density={density}
|
||||||
|
data-direction={direction}
|
||||||
|
data-size={size}
|
||||||
|
data-tone={tone}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
packages/ui/src/components/frames/FooterCell.tsx
Normal file
42
packages/ui/src/components/frames/FooterCell.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { UiStatusItem } from "../../types";
|
||||||
|
|
||||||
|
export interface FooterCellProps {
|
||||||
|
item: UiStatusItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FooterCell({ item }: FooterCellProps) {
|
||||||
|
const target = item.link?.external ? "_blank" : undefined;
|
||||||
|
const rel = item.link?.external ? "noreferrer" : undefined;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.link) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="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}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="footer-cell"
|
||||||
|
data-severity={item.severity || "neutral"}
|
||||||
|
data-model-id={item.id}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
packages/ui/src/components/frames/FooterStatusCell.tsx
Normal file
6
packages/ui/src/components/frames/FooterStatusCell.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiStatusItem } from "../../types";
|
||||||
|
import { FooterCell } from "./FooterCell";
|
||||||
|
|
||||||
|
export function FooterStatusCell({ item }: { item: UiStatusItem }) {
|
||||||
|
return <FooterCell item={item} />;
|
||||||
|
}
|
||||||
13
packages/ui/src/components/frames/GridFrame.tsx
Normal file
13
packages/ui/src/components/frames/GridFrame.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export interface GridFrameProps extends PropsWithChildren {
|
||||||
|
density?: "compact" | "dense";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridFrame({ children, density = "dense" }: GridFrameProps) {
|
||||||
|
return (
|
||||||
|
<section className="grid-frame" data-density={density}>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
packages/ui/src/components/frames/ModuleCard.tsx
Normal file
32
packages/ui/src/components/frames/ModuleCard.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { UiModuleBlock } from "../../types";
|
||||||
|
import { IconGlyph } from "../foundation/IconGlyph";
|
||||||
|
|
||||||
|
export interface ModuleCardProps {
|
||||||
|
module: UiModuleBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleCard({ module }: ModuleCardProps) {
|
||||||
|
const generatedId = useId();
|
||||||
|
const titleId = `${generatedId}-title`;
|
||||||
|
const ariaLabel = module.title ? undefined : module.label || module.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="module-card"
|
||||||
|
data-severity={module.severity || "neutral"}
|
||||||
|
data-model-id={module.id}
|
||||||
|
aria-labelledby={module.title ? titleId : undefined}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{module.title ? <h2 id={titleId}>{module.title}</h2> : null}
|
||||||
|
{module.value ? <strong>{module.value}</strong> : null}
|
||||||
|
{module.detail || module.label ? (
|
||||||
|
<p>{module.detail || module.label}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{module.icon ? <IconGlyph name={module.icon} size="lg" /> : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
packages/ui/src/components/frames/Panel.tsx
Normal file
19
packages/ui/src/components/frames/Panel.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export interface PanelProps extends PropsWithChildren {
|
||||||
|
title?: string;
|
||||||
|
density?: "compact" | "dense";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Panel({ title, density = "dense", children }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<section className="panel" data-density={density}>
|
||||||
|
{title ? (
|
||||||
|
<header className="panel__header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className="panel__body">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
packages/ui/src/components/frames/ScanlineField.tsx
Normal file
21
packages/ui/src/components/frames/ScanlineField.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export interface ScanlineFieldProps {
|
||||||
|
intensity?: "soft" | "medium" | "hard";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
tone?: "neutral" | "accent" | "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanlineField({
|
||||||
|
intensity = "medium",
|
||||||
|
size = "md",
|
||||||
|
tone = "neutral",
|
||||||
|
}: ScanlineFieldProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="scanline-field"
|
||||||
|
data-intensity={intensity}
|
||||||
|
data-size={size}
|
||||||
|
data-tone={tone}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
packages/ui/src/components/frames/index.ts
Normal file
10
packages/ui/src/components/frames/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export * from "./CornerBracketFrame";
|
||||||
|
export * from "./DashboardFrame";
|
||||||
|
export * from "./DashboardHeader";
|
||||||
|
export * from "./DiagonalStripeField";
|
||||||
|
export * from "./FooterCell";
|
||||||
|
export * from "./FooterStatusCell";
|
||||||
|
export * from "./GridFrame";
|
||||||
|
export * from "./ModuleCard";
|
||||||
|
export * from "./Panel";
|
||||||
|
export * from "./ScanlineField";
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiServiceGroup } from "../../types";
|
||||||
|
import { ServicePanel } from "./ServicePanel";
|
||||||
|
|
||||||
|
export function ServiceGroupPanel({ group }: { group: UiServiceGroup }) {
|
||||||
|
return <ServicePanel group={group} />;
|
||||||
|
}
|
||||||
27
packages/ui/src/components/operations/ServicePanel.tsx
Normal file
27
packages/ui/src/components/operations/ServicePanel.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { UiServiceGroup } from "../../types";
|
||||||
|
import { Panel } from "../frames/Panel";
|
||||||
|
import { ServiceRow } from "./ServiceRow";
|
||||||
|
import { StatusStrip } from "./StatusStrip";
|
||||||
|
|
||||||
|
export interface ServicePanelProps {
|
||||||
|
group: UiServiceGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicePanel({ group }: ServicePanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="service-panel"
|
||||||
|
data-layout={group.layout || "list"}
|
||||||
|
data-model-id={group.id}
|
||||||
|
>
|
||||||
|
<Panel title={group.title}>
|
||||||
|
{group.summary?.length ? (
|
||||||
|
<StatusStrip id={`${group.id}:summary`} items={group.summary} compact />
|
||||||
|
) : null}
|
||||||
|
{group.services.map((service) => (
|
||||||
|
<ServiceRow key={service.id} service={service} />
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
packages/ui/src/components/operations/ServiceRow.tsx
Normal file
56
packages/ui/src/components/operations/ServiceRow.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { UiServiceRow } from "../../types";
|
||||||
|
import { IconGlyph } from "../foundation/IconGlyph";
|
||||||
|
import { StatusBadge } from "../foundation/StatusBadge";
|
||||||
|
|
||||||
|
export interface ServiceRowProps {
|
||||||
|
service: UiServiceRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceRow({ service }: ServiceRowProps) {
|
||||||
|
const target = service.link?.external ? "_blank" : undefined;
|
||||||
|
const rel = service.link?.external ? "noreferrer" : undefined;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="service-row__status" aria-hidden="true" />
|
||||||
|
<IconGlyph name={service.icon} />
|
||||||
|
<div className="service-row__main">
|
||||||
|
<h3>{service.label}</h3>
|
||||||
|
<p>{service.description}</p>
|
||||||
|
</div>
|
||||||
|
{service.detail ? (
|
||||||
|
<StatusBadge label={service.detail} severity={service.severity} />
|
||||||
|
) : null}
|
||||||
|
{service.link ? (
|
||||||
|
<span className="service-row__launch" aria-hidden="true">
|
||||||
|
<IconGlyph name="mdi:open-in-new" size="sm" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (service.link) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="service-row"
|
||||||
|
data-severity={service.severity}
|
||||||
|
data-model-id={service.id}
|
||||||
|
href={service.link.href}
|
||||||
|
target={target}
|
||||||
|
rel={rel}
|
||||||
|
aria-label={service.link.label}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="service-row"
|
||||||
|
data-severity={service.severity}
|
||||||
|
data-model-id={service.id}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
packages/ui/src/components/operations/StatusStrip.tsx
Normal file
23
packages/ui/src/components/operations/StatusStrip.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { UiStatusItem } from "../../types";
|
||||||
|
import { FooterCell } from "../frames/FooterCell";
|
||||||
|
|
||||||
|
export interface StatusStripProps {
|
||||||
|
id?: string;
|
||||||
|
items: UiStatusItem[];
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusStrip({ id, items, compact = false }: StatusStripProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="status-strip"
|
||||||
|
data-compact={compact}
|
||||||
|
data-model-id={id}
|
||||||
|
data-variant={compact ? "cluster" : "system-bus"}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<FooterCell key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/ui/src/components/operations/SystemState.tsx
Normal file
26
packages/ui/src/components/operations/SystemState.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { UiSeverity } from "../../types";
|
||||||
|
import { IconGlyph } from "../foundation/IconGlyph";
|
||||||
|
|
||||||
|
export interface SystemStateProps {
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
icon?: string;
|
||||||
|
severity?: UiSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemState({
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
icon = "mdi:information-outline",
|
||||||
|
severity = "neutral",
|
||||||
|
}: SystemStateProps) {
|
||||||
|
return (
|
||||||
|
<section className="system-state" data-severity={severity}>
|
||||||
|
<IconGlyph name={icon} size="lg" />
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{detail ? <p>{detail}</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
packages/ui/src/components/operations/WeatherModule.tsx
Normal file
6
packages/ui/src/components/operations/WeatherModule.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiModuleBlock } from "../../types";
|
||||||
|
import { ModuleCard } from "../frames/ModuleCard";
|
||||||
|
|
||||||
|
export function WeatherModule({ module }: { module: UiModuleBlock }) {
|
||||||
|
return <ModuleCard module={module} />;
|
||||||
|
}
|
||||||
6
packages/ui/src/components/operations/index.ts
Normal file
6
packages/ui/src/components/operations/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./ServiceGroupPanel";
|
||||||
|
export * from "./ServicePanel";
|
||||||
|
export * from "./ServiceRow";
|
||||||
|
export * from "./StatusStrip";
|
||||||
|
export * from "./SystemState";
|
||||||
|
export * from "./WeatherModule";
|
||||||
204
packages/ui/src/components/render.test.tsx
Normal file
204
packages/ui/src/components/render.test.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DashboardFrame,
|
||||||
|
FooterCell,
|
||||||
|
IconButton,
|
||||||
|
ServiceRow,
|
||||||
|
StatusStrip,
|
||||||
|
TelemetryCard,
|
||||||
|
TelemetryGrid,
|
||||||
|
ThemeToggle,
|
||||||
|
} from "../index";
|
||||||
|
import { dashboardPreviewFixtures } from "../fixtures";
|
||||||
|
|
||||||
|
describe("dashboard UI components", () => {
|
||||||
|
test("renders the primary generic dashboard fixture", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<DashboardFrame dashboard={dashboardPreviewFixtures.primary} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain("Operations Console");
|
||||||
|
expect(body).toContain("Core Throughput");
|
||||||
|
expect(body).toContain("Queue Workers");
|
||||||
|
expect(body).toContain("console-header");
|
||||||
|
expect(body).toContain("telemetry-section");
|
||||||
|
expect(body).toContain("6 Metrics");
|
||||||
|
expect(body).toContain('data-variant="system-bus"');
|
||||||
|
expect(body).toContain("data-icon-name=\"mdi:server-network\"");
|
||||||
|
expect(body).toContain("data-severity=\"warning\"");
|
||||||
|
expect(body).not.toContain("dashboard-title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders another generic dashboard fixture through the same component", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<DashboardFrame dashboard={dashboardPreviewFixtures.secondary} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain("Support Desk");
|
||||||
|
expect(body).toContain("Response Window");
|
||||||
|
expect(body).toContain("Regional Nodes");
|
||||||
|
expect(body).toContain("data-icon-name=\"mdi:headset\"");
|
||||||
|
expect(body).toContain("data-severity=\"loading\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders optional service and status links as focusable anchors", () => {
|
||||||
|
const service = renderToString(
|
||||||
|
<ServiceRow
|
||||||
|
service={{
|
||||||
|
id: "linked-service",
|
||||||
|
label: "Linked Service",
|
||||||
|
description: "Generic linked destination",
|
||||||
|
severity: "ok",
|
||||||
|
detail: "ready",
|
||||||
|
link: { href: "https://example.test/service", external: true },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const footer = renderToString(
|
||||||
|
<FooterCell
|
||||||
|
item={{
|
||||||
|
id: "linked-status",
|
||||||
|
label: "Linked Status",
|
||||||
|
value: "open",
|
||||||
|
severity: "neutral",
|
||||||
|
link: { href: "https://example.test/status" },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(service).toContain("<a ");
|
||||||
|
expect(service).toContain("href=\"https://example.test/service\"");
|
||||||
|
expect(service).toContain("target=\"_blank\"");
|
||||||
|
expect(service).toContain('class="service-row__status"');
|
||||||
|
expect(service).toContain('class="service-row__launch"');
|
||||||
|
expect(footer).toContain("<a ");
|
||||||
|
expect(footer).toContain("href=\"https://example.test/status\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the status strip as a compact system bus", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<StatusStrip
|
||||||
|
id="dashboard-status"
|
||||||
|
items={[
|
||||||
|
{ id: "system", label: "System", value: "Nominal", severity: "ok" },
|
||||||
|
{ id: "refresh", label: "Refresh", value: "15s", severity: "neutral" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('class="status-strip"');
|
||||||
|
expect(body).toContain('data-variant="system-bus"');
|
||||||
|
expect(body).toContain("Nominal");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders stable model IDs on group and status containers", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<DashboardFrame dashboard={dashboardPreviewFixtures.primary} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain("data-model-id=\"queue-workers\"");
|
||||||
|
expect(body).toContain("data-model-id=\"dashboard-status\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not render progress bars for non-percent metrics without explicit progress", () => {
|
||||||
|
const withoutProgress = renderToString(
|
||||||
|
<TelemetryCard
|
||||||
|
card={{
|
||||||
|
id: "bytes",
|
||||||
|
label: "Bytes Metric",
|
||||||
|
value: { kind: "bytes", value: 2048 },
|
||||||
|
severity: "neutral",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const withProgress = renderToString(
|
||||||
|
<TelemetryCard
|
||||||
|
card={{
|
||||||
|
id: "bytes-with-progress",
|
||||||
|
label: "Bytes With Progress",
|
||||||
|
value: { kind: "bytes", value: 2048 },
|
||||||
|
progress: 42,
|
||||||
|
severity: "neutral",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(withoutProgress).not.toContain("telemetry-card__bar");
|
||||||
|
expect(withProgress).toContain("--metric-progress:42%");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders telemetry trends through the uPlot chart surface", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<TelemetryCard
|
||||||
|
card={{
|
||||||
|
id: "trend-card",
|
||||||
|
label: "Trend Card",
|
||||||
|
value: { kind: "percent", value: 64 },
|
||||||
|
sparkline: [18, 24, 64],
|
||||||
|
severity: "ok",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('data-chart-library="uplot"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders telemetry as a framed instrument section with indexed cards", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<TelemetryGrid cards={dashboardPreviewFixtures.primary.telemetry.slice(0, 2)} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('class="telemetry-section"');
|
||||||
|
expect(body).toContain("Telemetry");
|
||||||
|
expect(body).toContain("2 Metrics");
|
||||||
|
expect(body).toContain('data-metric-index="01"');
|
||||||
|
expect(body).toContain('class="telemetry-card__index"');
|
||||||
|
expect(body).toContain('class="telemetry-card__source"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("base button controls forward native attributes", () => {
|
||||||
|
const button = renderToString(
|
||||||
|
<Button
|
||||||
|
label="Refresh"
|
||||||
|
id="refresh-action"
|
||||||
|
className="custom-action"
|
||||||
|
aria-controls="refresh-target"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const iconButton = renderToString(
|
||||||
|
<IconButton
|
||||||
|
icon="mdi:refresh"
|
||||||
|
label="Refresh status"
|
||||||
|
id="refresh-icon-action"
|
||||||
|
className="custom-icon-action"
|
||||||
|
aria-expanded="false"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(button).toContain("id=\"refresh-action\"");
|
||||||
|
expect(button).toContain("class=\"ui-button custom-action ");
|
||||||
|
expect(button).toContain("aria-controls=\"refresh-target\"");
|
||||||
|
expect(iconButton).toContain("id=\"refresh-icon-action\"");
|
||||||
|
expect(iconButton).toContain("class=\"icon-button custom-icon-action ");
|
||||||
|
expect(iconButton).toContain("aria-expanded=\"false\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the theme toggle as a segmented instrument control", () => {
|
||||||
|
const body = renderToString(
|
||||||
|
<ThemeToggle theme="light" onThemeChange={() => undefined} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('class="theme-toggle"');
|
||||||
|
expect(body).toContain('data-ui-theme-current="light"');
|
||||||
|
expect(body).toContain('aria-label="Light theme"');
|
||||||
|
expect(body).toContain('aria-pressed="true"');
|
||||||
|
expect(body).toContain('class="theme-toggle__label"');
|
||||||
|
expect(body).toContain('class="theme-toggle__switch"');
|
||||||
|
expect(body).toContain('data-active="true"');
|
||||||
|
expect(body).toContain("Theme");
|
||||||
|
expect(body).toContain("Dark");
|
||||||
|
expect(body).toContain("Light");
|
||||||
|
});
|
||||||
|
});
|
||||||
957
packages/ui/src/components/styles.css
Normal file
957
packages/ui/src/components/styles.css
Normal file
|
|
@ -0,0 +1,957 @@
|
||||||
|
.dashboard-frame {
|
||||||
|
display: grid;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 0.45rem;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: clamp(0.5rem, 0.72vw, 0.74rem);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% 12%, transparent 0 44%, rgba(0, 0, 0, 0.14) 100%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent 9rem),
|
||||||
|
var(--ui-color-frame-wash);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__header,
|
||||||
|
.dashboard-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.48rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__header {
|
||||||
|
grid-template-columns: minmax(28rem, 1fr) minmax(31rem, 0.92fr);
|
||||||
|
min-height: 4.82rem;
|
||||||
|
border: var(--ui-border-strong);
|
||||||
|
background: var(--ui-color-surface-panel);
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
padding: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__title {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.58rem;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 3.72rem;
|
||||||
|
border-right: var(--ui-border);
|
||||||
|
padding-right: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__title-copy {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__actions {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: var(--ui-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__header p,
|
||||||
|
.dashboard-frame__header span,
|
||||||
|
.dashboard-header p,
|
||||||
|
.dashboard-header span,
|
||||||
|
.dashboard-frame h1,
|
||||||
|
.dashboard-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__header p,
|
||||||
|
.dashboard-frame__header span,
|
||||||
|
.dashboard-header p,
|
||||||
|
.dashboard-header span {
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame h1,
|
||||||
|
.dashboard-header h1 {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: var(--ui-font-display);
|
||||||
|
font-size: clamp(2.05rem, 3vw, 2.78rem);
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 0.82;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__modules {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(15rem, 0.66fr) minmax(17rem, 1fr);
|
||||||
|
justify-content: end;
|
||||||
|
gap: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels .service-panel[data-layout="grid"] {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-section {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
border: var(--ui-border-strong);
|
||||||
|
background: var(--ui-color-surface-panel);
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-section__header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.48rem;
|
||||||
|
border-bottom: var(--ui-border);
|
||||||
|
padding: 0.22rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-section__header p,
|
||||||
|
.telemetry-section__header h2,
|
||||||
|
.telemetry-section__header span {
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-section__header p {
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.46rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-section__header h2 {
|
||||||
|
color: var(--ui-color-text);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-section__header > span {
|
||||||
|
border: var(--ui-border);
|
||||||
|
color: var(--ui-color-text-secondary);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.18rem 0.36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--ui-color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card,
|
||||||
|
.module-card,
|
||||||
|
.panel,
|
||||||
|
.system-state {
|
||||||
|
border: var(--ui-border);
|
||||||
|
background: var(--ui-color-surface-panel);
|
||||||
|
color: var(--ui-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
min-height: 4.72rem;
|
||||||
|
gap: 0.22rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--ui-color-card-gradient-start),
|
||||||
|
var(--ui-color-card-gradient-end)
|
||||||
|
);
|
||||||
|
border-left: 2px solid var(--ui-color-ok);
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
padding: 0.34rem 0.46rem 0.36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card[data-severity="warning"] {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
|
||||||
|
border-left-color: var(--ui-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card[data-severity="danger"] {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 22%);
|
||||||
|
border-left-color: var(--ui-color-danger);
|
||||||
|
color: var(--ui-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card[data-severity="stale"],
|
||||||
|
.telemetry-card[data-severity="unavailable"] {
|
||||||
|
border-left-color: var(--ui-color-stale);
|
||||||
|
color: var(--ui-color-stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card[data-severity="loading"] {
|
||||||
|
border-left-color: var(--ui-color-accent);
|
||||||
|
color: var(--ui-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card:hover {
|
||||||
|
background: var(--ui-color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card header .icon-glyph {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-color: var(--ui-color-border-subtle);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card h3,
|
||||||
|
.telemetry-card p,
|
||||||
|
.module-card h2,
|
||||||
|
.module-card p,
|
||||||
|
.service-row h3,
|
||||||
|
.service-row p,
|
||||||
|
.panel h2,
|
||||||
|
.system-state h2,
|
||||||
|
.system-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card h3,
|
||||||
|
.service-row h3 {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.05;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card h3 {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--ui-color-text-secondary);
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.02;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card__index {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 1.05rem;
|
||||||
|
margin-bottom: 0.08rem;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.45rem;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row h3 {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card strong,
|
||||||
|
.module-card strong {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--ui-font-display);
|
||||||
|
font-size: clamp(1.45rem, 2.25vw, 2.05rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 0.78;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card p,
|
||||||
|
.module-card p,
|
||||||
|
.service-row p {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
line-height: 1.08;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card p,
|
||||||
|
.telemetry-card__source {
|
||||||
|
font-size: 0.49rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card p,
|
||||||
|
.service-row p {
|
||||||
|
font-size: 0.56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card__bar,
|
||||||
|
.progress-meter {
|
||||||
|
height: 0.2rem;
|
||||||
|
border: var(--ui-border);
|
||||||
|
background: var(--ui-color-surface-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card__bar span,
|
||||||
|
.progress-meter span {
|
||||||
|
display: block;
|
||||||
|
width: var(--metric-progress, var(--meter-progress));
|
||||||
|
height: 100%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card[data-severity="loading"] .telemetry-card__bar span {
|
||||||
|
min-width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-chart {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
height: 0.86rem;
|
||||||
|
color: var(--ui-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-chart::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 50% 0 auto;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--ui-color-border-subtle);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 .uplot {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--ui-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-chart__canvas .u-over,
|
||||||
|
.line-chart__canvas .u-under {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: start;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 3.72rem;
|
||||||
|
border: var(--ui-border);
|
||||||
|
background: var(--ui-color-surface-module);
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
padding: 0.42rem 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card h2 {
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.48rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card strong {
|
||||||
|
margin-top: 0.12rem;
|
||||||
|
font-size: clamp(1.1rem, 1.55vw, 1.56rem);
|
||||||
|
line-height: 0.82;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card p {
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.45rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card[data-severity="ok"] .icon-glyph {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 44%);
|
||||||
|
background: var(--ui-color-accent-soft);
|
||||||
|
color: var(--ui-color-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.28rem auto minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.24rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.08rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--ui-border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.14rem 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row__status {
|
||||||
|
width: 0.26rem;
|
||||||
|
height: 0.72rem;
|
||||||
|
background: var(--ui-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="ok"] .service-row__status {
|
||||||
|
background: var(--ui-color-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="warning"] {
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--ui-color-warning), transparent 54%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="warning"] .service-row__status {
|
||||||
|
background: var(--ui-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="danger"],
|
||||||
|
.service-row[data-severity="unavailable"] {
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--ui-color-danger), transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="danger"] .service-row__status,
|
||||||
|
.service-row[data-severity="unavailable"] .service-row__status {
|
||||||
|
background: var(--ui-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="loading"] {
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row[data-severity="loading"] .service-row__status {
|
||||||
|
background: var(--ui-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row:where(a):hover {
|
||||||
|
border-bottom-color: var(--ui-color-accent);
|
||||||
|
background: var(--ui-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row__launch {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row__launch .icon-glyph {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-color: var(--ui-color-border-subtle);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-grid;
|
||||||
|
min-height: 1.12rem;
|
||||||
|
align-items: center;
|
||||||
|
border: var(--ui-border);
|
||||||
|
background: var(--ui-color-surface-inset);
|
||||||
|
padding: 0 0.32rem;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-severity="ok"] {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 42%);
|
||||||
|
color: var(--ui-color-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-severity="warning"] {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 35%);
|
||||||
|
color: var(--ui-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-severity="danger"] {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 30%);
|
||||||
|
color: var(--ui-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-severity="stale"],
|
||||||
|
.status-badge[data-severity="unavailable"] {
|
||||||
|
color: var(--ui-color-stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-severity="loading"] {
|
||||||
|
color: var(--ui-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-cell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
min-height: 1.72rem;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--ui-color-surface-footer);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-cell span,
|
||||||
|
.footer-cell strong {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.29rem 0.52rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-cell span {
|
||||||
|
height: 100%;
|
||||||
|
border-right: var(--ui-border);
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.48rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-cell strong {
|
||||||
|
color: var(--ui-color-text);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-cell[data-severity="ok"] strong {
|
||||||
|
color: var(--ui-color-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
border: var(--ui-border-strong);
|
||||||
|
background: var(--ui-color-border);
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-strip[data-compact="true"] {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--ui-color-surface-panel);
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
border-bottom: var(--ui-border);
|
||||||
|
padding: 0.38rem 0.55rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
font-family: var(--ui-font-display);
|
||||||
|
font-size: clamp(1.1rem, 1.62vw, 1.55rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 0.9;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__body {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.42rem 0.55rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-panel .panel__body {
|
||||||
|
gap: 0;
|
||||||
|
padding-block: 0.18rem 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-panel[data-layout="grid"] .service-row {
|
||||||
|
min-height: 1.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button,
|
||||||
|
.icon-button {
|
||||||
|
display: inline-grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--ui-border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
gap: var(--ui-space-2);
|
||||||
|
min-height: 2.5rem;
|
||||||
|
background: var(--ui-color-accent);
|
||||||
|
color: var(--ui-color-canvas);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 850;
|
||||||
|
padding: 0 var(--ui-space-4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--ui-color-surface-footer);
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.72rem minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
width: 9.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 2.18rem;
|
||||||
|
border: var(--ui-border-strong);
|
||||||
|
background: var(--ui-color-surface-module);
|
||||||
|
color: var(--ui-color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0;
|
||||||
|
padding: 0.13rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: var(--ui-shadow-inset-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
border-color: var(--ui-color-accent);
|
||||||
|
background: var(--ui-color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:active {
|
||||||
|
background: var(--ui-color-surface-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__label {
|
||||||
|
display: grid;
|
||||||
|
align-self: stretch;
|
||||||
|
place-items: center;
|
||||||
|
border-right: var(--ui-border);
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
font-size: 0.48rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__switch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__cell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
gap: 0.2rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 1.68rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--ui-color-muted);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.22rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__cell[data-active="true"] {
|
||||||
|
border-color: var(--ui-color-accent);
|
||||||
|
background: var(--ui-color-accent-soft);
|
||||||
|
color: var(--ui-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__cell > span {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover .theme-toggle__cell[data-active="false"] {
|
||||||
|
border-color: var(--ui-color-line);
|
||||||
|
color: var(--ui-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .icon-glyph {
|
||||||
|
width: 0.82rem;
|
||||||
|
height: 0.82rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .icon-glyph svg {
|
||||||
|
width: 0.78rem;
|
||||||
|
height: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-glyph {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 1.55rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
place-items: center;
|
||||||
|
border: var(--ui-border);
|
||||||
|
background: var(--ui-color-surface-icon);
|
||||||
|
color: currentColor;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-glyph 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-state {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--ui-space-3);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--ui-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
background: var(--ui-color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator[data-orientation="horizontal"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
margin-block: var(--ui-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-bracket-frame,
|
||||||
|
.diagonal-stripe-field,
|
||||||
|
.scanline-field,
|
||||||
|
.signal-trace {
|
||||||
|
display: block;
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.telemetry-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px), (max-height: 840px) {
|
||||||
|
.dashboard-frame {
|
||||||
|
grid-template-rows: auto auto auto auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.dashboard-frame__header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.dashboard-frame__header,
|
||||||
|
.dashboard-frame__title,
|
||||||
|
.dashboard-header,
|
||||||
|
.dashboard-frame__modules,
|
||||||
|
.dashboard-frame__panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__header {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__title {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--ui-border);
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame h1,
|
||||||
|
.dashboard-header h1 {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.telemetry-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 580px) {
|
||||||
|
.service-row {
|
||||||
|
grid-template-columns: 0.28rem auto minmax(0, 1fr) auto;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row .status-badge {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row__launch {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-strip {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.status-strip {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
packages/ui/src/components/telemetry/LineChart.tsx
Normal file
114
packages/ui/src/components/telemetry/LineChart.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import "uplot/dist/uPlot.min.css";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { UiSeverity } from "../../types";
|
||||||
|
|
||||||
|
export interface LineChartProps {
|
||||||
|
values?: number[];
|
||||||
|
severity?: UiSeverity;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineChart({
|
||||||
|
values = [],
|
||||||
|
severity = "neutral",
|
||||||
|
label = "Telemetry trend",
|
||||||
|
}: LineChartProps) {
|
||||||
|
const chartElementRef = useRef<HTMLDivElement>(null);
|
||||||
|
const valuesKey = values.join(",");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chartElement = chartElementRef.current;
|
||||||
|
if (!chartElement) return;
|
||||||
|
|
||||||
|
let chart: ChartInstance | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
void import("uplot").then((module) => {
|
||||||
|
if (disposed || !chartElementRef.current) return;
|
||||||
|
|
||||||
|
const element = chartElementRef.current;
|
||||||
|
chart = new module.default(chartOptions(element, severity), chartData(values), element);
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
chart?.setSize(chartSize(element));
|
||||||
|
});
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
chart?.destroy();
|
||||||
|
};
|
||||||
|
}, [severity, valuesKey, values]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="line-chart"
|
||||||
|
data-chart-library="uplot"
|
||||||
|
data-severity={severity}
|
||||||
|
data-values={values.join(",")}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<div ref={chartElementRef} className="line-chart__canvas" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartInstance = {
|
||||||
|
destroy(): 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: 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: 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";
|
||||||
|
}
|
||||||
26
packages/ui/src/components/telemetry/SignalTrace.tsx
Normal file
26
packages/ui/src/components/telemetry/SignalTrace.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
export interface SignalTraceProps {
|
||||||
|
density?: "regular" | "tight";
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
tone?: "neutral" | "accent" | "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalTrace({
|
||||||
|
density = "regular",
|
||||||
|
orientation = "horizontal",
|
||||||
|
tone = "accent",
|
||||||
|
}: SignalTraceProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="signal-trace"
|
||||||
|
data-density={density}
|
||||||
|
data-orientation={orientation}
|
||||||
|
data-tone={tone}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="signal-trace__rail" />
|
||||||
|
<span className="signal-trace__node" data-node="start" />
|
||||||
|
<span className="signal-trace__node" data-node="middle" />
|
||||||
|
<span className="signal-trace__node" data-node="end" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
packages/ui/src/components/telemetry/Sparkline.tsx
Normal file
37
packages/ui/src/components/telemetry/Sparkline.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { UiSeverity } from "../../types";
|
||||||
|
|
||||||
|
export interface SparklineProps {
|
||||||
|
values?: number[];
|
||||||
|
severity?: UiSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sparkline({
|
||||||
|
values = [],
|
||||||
|
severity = "neutral",
|
||||||
|
}: SparklineProps) {
|
||||||
|
const points = toPoints(values);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="sparkline"
|
||||||
|
data-severity={severity}
|
||||||
|
viewBox="0 0 100 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{points ? <polyline points={points} /> : null}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPoints(values: number[]): string {
|
||||||
|
if (values.length === 0) return "";
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
|
||||||
|
return values
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = index * step;
|
||||||
|
const y = 24 - (Math.max(0, value) / max) * 22;
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
59
packages/ui/src/components/telemetry/TelemetryCard.tsx
Normal file
59
packages/ui/src/components/telemetry/TelemetryCard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { clampPercent, formatMetricValue } from "../../format";
|
||||||
|
import type { UiTelemetryCard } from "../../types";
|
||||||
|
import { IconGlyph } from "../foundation/IconGlyph";
|
||||||
|
import { LineChart } from "./LineChart";
|
||||||
|
|
||||||
|
export interface TelemetryCardProps {
|
||||||
|
card: UiTelemetryCard;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryCard({ card, index }: TelemetryCardProps) {
|
||||||
|
const progress = resolveProgress(card);
|
||||||
|
const value = formatMetricValue(card.value);
|
||||||
|
const metricIndex = index ? String(index).padStart(2, "0") : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="telemetry-card"
|
||||||
|
data-severity={card.severity}
|
||||||
|
data-model-id={card.id}
|
||||||
|
data-metric-index={metricIndex}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
{metricIndex ? (
|
||||||
|
<span className="telemetry-card__index">{metricIndex}</span>
|
||||||
|
) : null}
|
||||||
|
<h3>{card.label}</h3>
|
||||||
|
</div>
|
||||||
|
{card.icon ? <IconGlyph name={card.icon} size="sm" /> : null}
|
||||||
|
</header>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
{progress !== null ? (
|
||||||
|
<div className="telemetry-card__bar" aria-hidden="true">
|
||||||
|
<span style={{ "--metric-progress": `${progress}%` } as CSSProperties} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{card.sparkline?.length ? (
|
||||||
|
<LineChart
|
||||||
|
values={card.sparkline}
|
||||||
|
severity={card.severity}
|
||||||
|
label={`${card.label} trend`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{card.detail || card.description ? (
|
||||||
|
<p className="telemetry-card__source">{card.detail || card.description}</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProgress(card: UiTelemetryCard): number | null {
|
||||||
|
if (typeof card.progress === "number") return clampPercent(card.progress);
|
||||||
|
if (card.value.kind !== "percent") return null;
|
||||||
|
|
||||||
|
const progress = Number(card.value.value);
|
||||||
|
return Number.isFinite(progress) ? clampPercent(progress) : null;
|
||||||
|
}
|
||||||
29
packages/ui/src/components/telemetry/TelemetryGrid.tsx
Normal file
29
packages/ui/src/components/telemetry/TelemetryGrid.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { UiTelemetryCard } from "../../types";
|
||||||
|
import { TelemetryCard } from "./TelemetryCard";
|
||||||
|
|
||||||
|
export interface TelemetryGridProps {
|
||||||
|
cards: UiTelemetryCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryGrid({ cards }: TelemetryGridProps) {
|
||||||
|
const headingId = useId();
|
||||||
|
const metricCount = `${cards.length} ${cards.length === 1 ? "Metric" : "Metrics"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="telemetry-section" aria-labelledby={headingId}>
|
||||||
|
<header className="telemetry-section__header">
|
||||||
|
<div>
|
||||||
|
<p>Signal Matrix</p>
|
||||||
|
<h2 id={headingId}>Telemetry</h2>
|
||||||
|
</div>
|
||||||
|
<span>{metricCount}</span>
|
||||||
|
</header>
|
||||||
|
<div className="telemetry-grid">
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<TelemetryCard key={card.id} card={card} index={index + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
packages/ui/src/components/telemetry/TelemetryStrip.tsx
Normal file
6
packages/ui/src/components/telemetry/TelemetryStrip.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { UiTelemetryCard } from "../../types";
|
||||||
|
import { TelemetryGrid } from "./TelemetryGrid";
|
||||||
|
|
||||||
|
export function TelemetryStrip({ cards }: { cards: UiTelemetryCard[] }) {
|
||||||
|
return <TelemetryGrid cards={cards} />;
|
||||||
|
}
|
||||||
6
packages/ui/src/components/telemetry/index.ts
Normal file
6
packages/ui/src/components/telemetry/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./LineChart";
|
||||||
|
export * from "./SignalTrace";
|
||||||
|
export * from "./Sparkline";
|
||||||
|
export * from "./TelemetryCard";
|
||||||
|
export * from "./TelemetryGrid";
|
||||||
|
export * from "./TelemetryStrip";
|
||||||
87
packages/ui/src/content-boundary.test.ts
Normal file
87
packages/ui/src/content-boundary.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const uiSourceRoot = existsSync(join(root, "packages/ui/src"))
|
||||||
|
? join(root, "packages/ui/src")
|
||||||
|
: join(root, "src");
|
||||||
|
|
||||||
|
const forbiddenTerms = [
|
||||||
|
"dimensionlab",
|
||||||
|
"dimension lab",
|
||||||
|
"vaultwarden",
|
||||||
|
"forgejo",
|
||||||
|
"grafana",
|
||||||
|
"uptime kuma",
|
||||||
|
"prometheus",
|
||||||
|
"backrest",
|
||||||
|
"open webui",
|
||||||
|
"comfyui",
|
||||||
|
"adminer",
|
||||||
|
"cockpit",
|
||||||
|
"ollama",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("UI package content boundary", () => {
|
||||||
|
test("contains the reusable dashboard component inventory", () => {
|
||||||
|
expect(existsSync(join(uiSourceRoot, "index.ts"))).toBe(true);
|
||||||
|
expect(
|
||||||
|
existsSync(join(uiSourceRoot, "components/frames/DashboardFrame.tsx")),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
existsSync(join(uiSourceRoot, "components/foundation/ThemeToggle.tsx")),
|
||||||
|
).toBe(true);
|
||||||
|
expect(existsSync(join(uiSourceRoot, "styles.css"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps UI components grouped by domain instead of a flat bucket", () => {
|
||||||
|
const domainFolders = ["foundation", "frames", "operations", "telemetry"];
|
||||||
|
|
||||||
|
for (const folder of domainFolders) {
|
||||||
|
expect(existsSync(join(uiSourceRoot, "components", folder))).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatComponentFiles = readdirSync(join(uiSourceRoot, "components"))
|
||||||
|
.filter((entry) => entry.endsWith(".tsx") && !entry.endsWith(".test.tsx"))
|
||||||
|
.sort();
|
||||||
|
expect(flatComponentFiles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps environment-specific content out of reusable UI source", () => {
|
||||||
|
const source = readUiSource(uiSourceRoot).toLowerCase();
|
||||||
|
|
||||||
|
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not import website runtime modules", () => {
|
||||||
|
const source = readUiSource(uiSourceRoot);
|
||||||
|
|
||||||
|
expect(source).not.toMatch(
|
||||||
|
/from ["'](?:apps\/web|\$lib\/server|\$lib\/model)/,
|
||||||
|
);
|
||||||
|
expect(source).not.toContain("../web/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps icon rendering driven by icon identifiers", () => {
|
||||||
|
const source = readUiSource(uiSourceRoot);
|
||||||
|
|
||||||
|
expect(source).not.toContain("@iconify-json/");
|
||||||
|
expect(source).not.toContain("/icons/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readUiSource(path: string): string {
|
||||||
|
if (!existsSync(path)) return "";
|
||||||
|
|
||||||
|
const stats = statSync(path);
|
||||||
|
if (stats.isFile()) {
|
||||||
|
if (path.endsWith(".test.ts") || path.endsWith(".test.tsx")) return "";
|
||||||
|
if (!/\.(tsx|ts|css)$/.test(path)) return "";
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return readdirSync(path)
|
||||||
|
.map((entry) => readUiSource(join(path, entry)))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
1
packages/ui/src/css.d.ts
vendored
Normal file
1
packages/ui/src/css.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
declare module "*.css";
|
||||||
132
packages/ui/src/fixtures.ts
Normal file
132
packages/ui/src/fixtures.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import type { UiDashboardPreview } from "./types";
|
||||||
|
|
||||||
|
export const dashboardPreviewFixtures: Record<string, UiDashboardPreview> = {
|
||||||
|
primary: {
|
||||||
|
eyebrow: "Command Surface",
|
||||||
|
title: "Operations Console",
|
||||||
|
subtitle: "Capacity, latency, and queue health",
|
||||||
|
telemetry: [
|
||||||
|
metric("core-throughput", "Core Throughput", "mdi:server-network", 82, "ok"),
|
||||||
|
metric("edge-cache", "Edge Cache", "mdi:database-clock", 67, "neutral"),
|
||||||
|
metric("queue-depth", "Queue Depth", "mdi:tray-full", 74, "warning"),
|
||||||
|
metric("error-budget", "Error Budget", "mdi:chart-timeline-variant", 18, "danger"),
|
||||||
|
metric("build-latency", "Build Latency", "mdi:timer-sand", 23, "stale"),
|
||||||
|
metric("worker-load", "Worker Load", "mdi:cpu-64-bit", 55, "ok"),
|
||||||
|
],
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: "ambient-conditions",
|
||||||
|
title: "Local Node",
|
||||||
|
value: "21.4 C",
|
||||||
|
detail: "clear window",
|
||||||
|
icon: "mdi:weather-partly-cloudy",
|
||||||
|
severity: "ok",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
serviceGroups: [
|
||||||
|
{
|
||||||
|
id: "queue-workers",
|
||||||
|
title: "Queue Workers",
|
||||||
|
services: [
|
||||||
|
service("ingest", "Ingest", "Event intake pipeline", "mdi:arrow-collapse-down", "ok", "1.252 ms"),
|
||||||
|
service("scheduler", "Scheduler", "Timed job coordinator", "mdi:calendar-clock", "warning", "1.487 ms"),
|
||||||
|
service("archive", "Archive", "Cold storage transfer", "mdi:archive-arrow-down", "ok", "1.301 ms"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regional-nodes",
|
||||||
|
title: "Regional Nodes",
|
||||||
|
services: [
|
||||||
|
service("north", "North", "Primary traffic cell", "mdi:access-point", "ok", "899 ms"),
|
||||||
|
service("west", "West", "Replica traffic cell", "mdi:access-point-network", "neutral", "1.118 ms"),
|
||||||
|
service("south", "South", "Maintenance window", "mdi:wrench-clock", "stale", "paused"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "control-plane",
|
||||||
|
title: "Control Plane",
|
||||||
|
services: [
|
||||||
|
service("identity", "Identity", "Access token exchange", "mdi:account-key", "ok", "721 ms"),
|
||||||
|
service("policy", "Policy", "Rules evaluation", "mdi:shield-check", "ok", "812 ms"),
|
||||||
|
service("audit", "Audit", "Event ledger writer", "mdi:text-box-search", "warning", "1.442 ms"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
statusItems: [
|
||||||
|
{ id: "system", label: "System", value: "Nominal", severity: "ok" },
|
||||||
|
{ id: "sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" },
|
||||||
|
{ id: "uptime", label: "Uptime", value: "14d 08h", severity: "neutral" },
|
||||||
|
{ id: "refresh", label: "Refresh", value: "15s", severity: "neutral" },
|
||||||
|
],
|
||||||
|
statusStripId: "dashboard-status",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
eyebrow: "Support Surface",
|
||||||
|
title: "Support Desk",
|
||||||
|
subtitle: "Response, routing, and regional health",
|
||||||
|
telemetry: [
|
||||||
|
metric("response-window", "Response Window", "mdi:headset", 41, "ok"),
|
||||||
|
metric("ticket-load", "Ticket Load", "mdi:ticket-confirmation", 68, "warning"),
|
||||||
|
metric("handoff-drift", "Handoff Drift", "mdi:swap-horizontal", 11, "neutral"),
|
||||||
|
metric("coverage-gap", "Coverage Gap", "mdi:map-marker-alert", 7, "danger"),
|
||||||
|
metric("routing-warmup", "Routing Warmup", "mdi:progress-clock", 0, "loading"),
|
||||||
|
],
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: "shift-state",
|
||||||
|
title: "Shift State",
|
||||||
|
value: "covered",
|
||||||
|
detail: "static fixture",
|
||||||
|
icon: "mdi:clipboard-check-outline",
|
||||||
|
severity: "ok",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
serviceGroups: [
|
||||||
|
{
|
||||||
|
id: "regional-nodes",
|
||||||
|
title: "Regional Nodes",
|
||||||
|
services: [
|
||||||
|
service("desk-a", "Desk A", "Frontline queue", "mdi:monitor-dashboard", "ok", "843 ms"),
|
||||||
|
service("desk-b", "Desk B", "Escalation queue", "mdi:monitor-star", "warning", "1.204 ms"),
|
||||||
|
service("desk-c", "Desk C", "Overflow queue", "mdi:monitor-off", "unavailable", "offline"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
statusItems: [
|
||||||
|
{ id: "desk", label: "Desk", value: "Covered", severity: "ok" },
|
||||||
|
{ id: "handoff", label: "Handoff", value: "Pending", severity: "warning" },
|
||||||
|
{ id: "routing", label: "Routing", value: "Manual", severity: "neutral" },
|
||||||
|
],
|
||||||
|
statusStripId: "support-status",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function metric(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
icon: string,
|
||||||
|
progress: number,
|
||||||
|
severity: UiDashboardPreview["telemetry"][number]["severity"],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
progress,
|
||||||
|
severity,
|
||||||
|
value: { kind: "percent" as const, value: progress },
|
||||||
|
detail: "static fixture",
|
||||||
|
sparkline: [12, 18, 15, 28, 24, progress],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function service(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
icon: string,
|
||||||
|
severity: UiDashboardPreview["serviceGroups"][number]["services"][number]["severity"],
|
||||||
|
detail: string,
|
||||||
|
) {
|
||||||
|
return { id, label, description, icon, severity, detail };
|
||||||
|
}
|
||||||
43
packages/ui/src/format.ts
Normal file
43
packages/ui/src/format.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { UiMetricValue } from "./types";
|
||||||
|
|
||||||
|
const byteUnits = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
|
||||||
|
export function formatMetricValue(metric: UiMetricValue): string {
|
||||||
|
if (metric.kind === "text") return String(metric.value);
|
||||||
|
|
||||||
|
const value = typeof metric.value === "number" ? metric.value : Number(metric.value);
|
||||||
|
const precision = metric.precision ?? inferPrecision(value);
|
||||||
|
const unit = metric.unit ?? defaultUnit(metric.kind);
|
||||||
|
|
||||||
|
if (metric.kind === "bytes") return formatBytes(value, precision);
|
||||||
|
if (metric.kind === "percent") return `${value.toFixed(precision)}%`;
|
||||||
|
if (metric.kind === "temperature") return `${value.toFixed(precision)}°C`;
|
||||||
|
return `${value.toFixed(precision)}${unit ? ` ${unit}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampPercent(value = 0): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultUnit(kind: UiMetricValue["kind"]): string {
|
||||||
|
if (kind === "percent") return "%";
|
||||||
|
if (kind === "temperature") return "C";
|
||||||
|
if (kind === "latency") return "ms";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPrecision(value: number): number {
|
||||||
|
return Number.isInteger(value) ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number, precision: number): string {
|
||||||
|
let size = value;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < byteUnits.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(precision)} ${byteUnits[index]}`;
|
||||||
|
}
|
||||||
54
packages/ui/src/index.ts
Normal file
54
packages/ui/src/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
export * from "./components/foundation";
|
||||||
|
export * from "./components/frames";
|
||||||
|
export * from "./components/operations";
|
||||||
|
export * from "./components/telemetry";
|
||||||
|
export { Badge } from "./components/foundation/Badge";
|
||||||
|
export { Button } from "./components/foundation/Button";
|
||||||
|
export { IconGlyph } from "./components/foundation/IconGlyph";
|
||||||
|
export { IconButton } from "./components/foundation/IconButton";
|
||||||
|
export { ProgressMeter } from "./components/foundation/ProgressMeter";
|
||||||
|
export { Separator } from "./components/foundation/Separator";
|
||||||
|
export { StatusBadge } from "./components/foundation/StatusBadge";
|
||||||
|
export { ThemeToggle } from "./components/foundation/ThemeToggle";
|
||||||
|
export { CornerBracketFrame } from "./components/frames/CornerBracketFrame";
|
||||||
|
export { DashboardHeader } from "./components/frames/DashboardHeader";
|
||||||
|
export { DashboardFrame } from "./components/frames/DashboardFrame";
|
||||||
|
export { DiagonalStripeField } from "./components/frames/DiagonalStripeField";
|
||||||
|
export { FooterCell } from "./components/frames/FooterCell";
|
||||||
|
export { FooterStatusCell } from "./components/frames/FooterStatusCell";
|
||||||
|
export { GridFrame } from "./components/frames/GridFrame";
|
||||||
|
export { ModuleCard } from "./components/frames/ModuleCard";
|
||||||
|
export { Panel } from "./components/frames/Panel";
|
||||||
|
export { ScanlineField } from "./components/frames/ScanlineField";
|
||||||
|
export { ServiceGroupPanel } from "./components/operations/ServiceGroupPanel";
|
||||||
|
export { ServicePanel } from "./components/operations/ServicePanel";
|
||||||
|
export { ServiceRow } from "./components/operations/ServiceRow";
|
||||||
|
export { StatusStrip } from "./components/operations/StatusStrip";
|
||||||
|
export { SystemState } from "./components/operations/SystemState";
|
||||||
|
export { WeatherModule } from "./components/operations/WeatherModule";
|
||||||
|
export { LineChart } from "./components/telemetry/LineChart";
|
||||||
|
export { SignalTrace } from "./components/telemetry/SignalTrace";
|
||||||
|
export { Sparkline } from "./components/telemetry/Sparkline";
|
||||||
|
export { TelemetryCard } from "./components/telemetry/TelemetryCard";
|
||||||
|
export { TelemetryGrid } from "./components/telemetry/TelemetryGrid";
|
||||||
|
export { TelemetryStrip } from "./components/telemetry/TelemetryStrip";
|
||||||
|
export { dashboardPreviewFixtures } from "./fixtures";
|
||||||
|
export {
|
||||||
|
getNextUiTheme,
|
||||||
|
isUiTheme,
|
||||||
|
persistUiTheme,
|
||||||
|
resolveInitialUiTheme,
|
||||||
|
UI_THEME_STORAGE_KEY,
|
||||||
|
} from "./theme";
|
||||||
|
export type {
|
||||||
|
UiDashboardPreview,
|
||||||
|
UiLink,
|
||||||
|
UiMetricValue,
|
||||||
|
UiModuleBlock,
|
||||||
|
UiServiceGroup,
|
||||||
|
UiServiceRow,
|
||||||
|
UiSeverity,
|
||||||
|
UiStatusItem,
|
||||||
|
UiTelemetryCard,
|
||||||
|
} from "./types";
|
||||||
|
export type { UiTheme } from "./theme";
|
||||||
76
packages/ui/src/primitives/alert.tsx
Normal file
76
packages/ui/src/primitives/alert.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
49
packages/ui/src/primitives/badge.tsx
Normal file
49
packages/ui/src/primitives/badge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
67
packages/ui/src/primitives/button.tsx
Normal file
67
packages/ui/src/primitives/button.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
103
packages/ui/src/primitives/card.tsx
Normal file
103
packages/ui/src/primitives/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
29
packages/ui/src/primitives/progress.tsx
Normal file
29
packages/ui/src/primitives/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="size-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
28
packages/ui/src/primitives/separator.tsx
Normal file
28
packages/ui/src/primitives/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
13
packages/ui/src/primitives/skeleton.tsx
Normal file
13
packages/ui/src/primitives/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from "../utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
16
packages/ui/src/stories/Badge.stories.tsx
Normal file
16
packages/ui/src/stories/Badge.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Badge } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/Badge",
|
||||||
|
component: Badge,
|
||||||
|
args: {
|
||||||
|
label: "Ready",
|
||||||
|
severity: "ok",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Badge>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
21
packages/ui/src/stories/Button.stories.tsx
Normal file
21
packages/ui/src/stories/Button.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Button } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/Button",
|
||||||
|
component: Button,
|
||||||
|
args: {
|
||||||
|
label: "Refresh",
|
||||||
|
icon: "mdi:refresh",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Button>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Primary: Story = {};
|
||||||
|
export const Secondary: Story = {
|
||||||
|
args: {
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
};
|
||||||
16
packages/ui/src/stories/CornerBracketFrame.stories.tsx
Normal file
16
packages/ui/src/stories/CornerBracketFrame.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { CornerBracketFrame } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/CornerBracketFrame",
|
||||||
|
component: CornerBracketFrame,
|
||||||
|
args: {
|
||||||
|
tone: "accent",
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof CornerBracketFrame>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
21
packages/ui/src/stories/DashboardFrame.stories.tsx
Normal file
21
packages/ui/src/stories/DashboardFrame.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { DashboardFrame } from "../index";
|
||||||
|
import { fullCompositionDashboard, secondaryDashboard } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/DashboardFrame",
|
||||||
|
component: DashboardFrame,
|
||||||
|
args: {
|
||||||
|
dashboard: fullCompositionDashboard,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DashboardFrame>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const FullComposition: Story = {};
|
||||||
|
export const Secondary: Story = {
|
||||||
|
args: {
|
||||||
|
dashboard: secondaryDashboard,
|
||||||
|
},
|
||||||
|
};
|
||||||
19
packages/ui/src/stories/DashboardHeader.stories.tsx
Normal file
19
packages/ui/src/stories/DashboardHeader.stories.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { DashboardHeader } from "../index";
|
||||||
|
import { moduleBlocks } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/DashboardHeader",
|
||||||
|
component: DashboardHeader,
|
||||||
|
args: {
|
||||||
|
eyebrow: "Preview Surface",
|
||||||
|
title: "Operations Console",
|
||||||
|
subtitle: "Generic dashboard header",
|
||||||
|
module: moduleBlocks.compact,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DashboardHeader>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const WithModule: Story = {};
|
||||||
16
packages/ui/src/stories/DashboardOnePager.stories.tsx
Normal file
16
packages/ui/src/stories/DashboardOnePager.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { DashboardFrame } from "../index";
|
||||||
|
import { fullCompositionDashboard } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/DashboardOnePager",
|
||||||
|
component: DashboardFrame,
|
||||||
|
args: {
|
||||||
|
dashboard: fullCompositionDashboard,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DashboardFrame>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const OnePager: Story = {};
|
||||||
16
packages/ui/src/stories/DiagonalStripeField.stories.tsx
Normal file
16
packages/ui/src/stories/DiagonalStripeField.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { DiagonalStripeField } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/DiagonalStripeField",
|
||||||
|
component: DiagonalStripeField,
|
||||||
|
args: {
|
||||||
|
tone: "accent",
|
||||||
|
density: "regular",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DiagonalStripeField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
21
packages/ui/src/stories/FooterCell.stories.tsx
Normal file
21
packages/ui/src/stories/FooterCell.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { FooterCell } from "../index";
|
||||||
|
import { statusItems } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/FooterCell",
|
||||||
|
component: FooterCell,
|
||||||
|
args: {
|
||||||
|
item: statusItems.ok,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof FooterCell>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Operational: Story = {};
|
||||||
|
export const Linked: Story = {
|
||||||
|
args: {
|
||||||
|
item: statusItems.action,
|
||||||
|
},
|
||||||
|
};
|
||||||
16
packages/ui/src/stories/FooterStatusCell.stories.tsx
Normal file
16
packages/ui/src/stories/FooterStatusCell.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { FooterStatusCell } from "../index";
|
||||||
|
import { statusItems } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/FooterStatusCell",
|
||||||
|
component: FooterStatusCell,
|
||||||
|
args: {
|
||||||
|
item: statusItems.degraded,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof FooterStatusCell>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Warning: Story = {};
|
||||||
24
packages/ui/src/stories/GridFrame.stories.tsx
Normal file
24
packages/ui/src/stories/GridFrame.stories.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { GridFrame, ModuleCard } from "../index";
|
||||||
|
import { moduleBlocks } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/GridFrame",
|
||||||
|
component: GridFrame,
|
||||||
|
render: (args) => (
|
||||||
|
<GridFrame {...args}>
|
||||||
|
<ModuleCard module={moduleBlocks.compact} />
|
||||||
|
<ModuleCard module={moduleBlocks.unavailable} />
|
||||||
|
<ModuleCard module={moduleBlocks.long} />
|
||||||
|
</GridFrame>
|
||||||
|
),
|
||||||
|
} satisfies Meta<typeof GridFrame>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Dense: Story = {
|
||||||
|
args: {
|
||||||
|
density: "dense",
|
||||||
|
},
|
||||||
|
};
|
||||||
16
packages/ui/src/stories/IconButton.stories.tsx
Normal file
16
packages/ui/src/stories/IconButton.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { IconButton } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/IconButton",
|
||||||
|
component: IconButton,
|
||||||
|
args: {
|
||||||
|
icon: "mdi:refresh",
|
||||||
|
label: "Refresh status",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof IconButton>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
17
packages/ui/src/stories/IconGlyph.stories.tsx
Normal file
17
packages/ui/src/stories/IconGlyph.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { IconGlyph } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/IconGlyph",
|
||||||
|
component: IconGlyph,
|
||||||
|
args: {
|
||||||
|
name: "mdi:server-network",
|
||||||
|
label: "Generic service",
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof IconGlyph>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
17
packages/ui/src/stories/LineChart.stories.tsx
Normal file
17
packages/ui/src/stories/LineChart.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { LineChart } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/LineChart",
|
||||||
|
component: LineChart,
|
||||||
|
args: {
|
||||||
|
values: [12, 18, 24, 20, 36, 44],
|
||||||
|
severity: "ok",
|
||||||
|
label: "Generic trend",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof LineChart>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Trend: Story = {};
|
||||||
21
packages/ui/src/stories/ModuleCard.stories.tsx
Normal file
21
packages/ui/src/stories/ModuleCard.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ModuleCard } from "../index";
|
||||||
|
import { moduleBlocks } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/ModuleCard",
|
||||||
|
component: ModuleCard,
|
||||||
|
args: {
|
||||||
|
module: moduleBlocks.compact,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ModuleCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Compact: Story = {};
|
||||||
|
export const Long: Story = {
|
||||||
|
args: {
|
||||||
|
module: moduleBlocks.long,
|
||||||
|
},
|
||||||
|
};
|
||||||
22
packages/ui/src/stories/Panel.stories.tsx
Normal file
22
packages/ui/src/stories/Panel.stories.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Panel, ServiceRow } from "../index";
|
||||||
|
import { serviceRows } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/Panel",
|
||||||
|
component: Panel,
|
||||||
|
render: (args) => (
|
||||||
|
<Panel {...args}>
|
||||||
|
<ServiceRow service={serviceRows.normal} />
|
||||||
|
<ServiceRow service={serviceRows.degraded} />
|
||||||
|
</Panel>
|
||||||
|
),
|
||||||
|
args: {
|
||||||
|
title: "Generic Panel",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Panel>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Dense: Story = {};
|
||||||
17
packages/ui/src/stories/ProgressMeter.stories.tsx
Normal file
17
packages/ui/src/stories/ProgressMeter.stories.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ProgressMeter } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/ProgressMeter",
|
||||||
|
component: ProgressMeter,
|
||||||
|
args: {
|
||||||
|
value: 72,
|
||||||
|
severity: "ok",
|
||||||
|
label: "Capacity",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ProgressMeter>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Filled: Story = {};
|
||||||
16
packages/ui/src/stories/ScanlineField.stories.tsx
Normal file
16
packages/ui/src/stories/ScanlineField.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ScanlineField } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/ScanlineField",
|
||||||
|
component: ScanlineField,
|
||||||
|
args: {
|
||||||
|
tone: "accent",
|
||||||
|
intensity: "medium",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ScanlineField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
15
packages/ui/src/stories/Separator.stories.tsx
Normal file
15
packages/ui/src/stories/Separator.stories.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Separator } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/Separator",
|
||||||
|
component: Separator,
|
||||||
|
args: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Separator>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Horizontal: Story = {};
|
||||||
16
packages/ui/src/stories/ServiceGroupPanel.stories.tsx
Normal file
16
packages/ui/src/stories/ServiceGroupPanel.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ServiceGroupPanel } from "../index";
|
||||||
|
import { serviceGroups } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/ServiceGroupPanel",
|
||||||
|
component: ServiceGroupPanel,
|
||||||
|
args: {
|
||||||
|
group: serviceGroups.mixed,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ServiceGroupPanel>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Mixed: Story = {};
|
||||||
16
packages/ui/src/stories/ServicePanel.stories.tsx
Normal file
16
packages/ui/src/stories/ServicePanel.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ServicePanel } from "../index";
|
||||||
|
import { serviceGroups } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/ServicePanel",
|
||||||
|
component: ServicePanel,
|
||||||
|
args: {
|
||||||
|
group: serviceGroups.long,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ServicePanel>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const LongList: Story = {};
|
||||||
21
packages/ui/src/stories/ServiceRow.stories.tsx
Normal file
21
packages/ui/src/stories/ServiceRow.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ServiceRow } from "../index";
|
||||||
|
import { serviceRows } from "./story-data";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/ServiceRow",
|
||||||
|
component: ServiceRow,
|
||||||
|
args: {
|
||||||
|
service: serviceRows.normal,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ServiceRow>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Normal: Story = {};
|
||||||
|
export const Warning: Story = {
|
||||||
|
args: {
|
||||||
|
service: serviceRows.degraded,
|
||||||
|
},
|
||||||
|
};
|
||||||
16
packages/ui/src/stories/SignalTrace.stories.tsx
Normal file
16
packages/ui/src/stories/SignalTrace.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { SignalTrace } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/SignalTrace",
|
||||||
|
component: SignalTrace,
|
||||||
|
args: {
|
||||||
|
tone: "accent",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof SignalTrace>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Horizontal: Story = {};
|
||||||
16
packages/ui/src/stories/Sparkline.stories.tsx
Normal file
16
packages/ui/src/stories/Sparkline.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Sparkline } from "../index";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "UI/Sparkline",
|
||||||
|
component: Sparkline,
|
||||||
|
args: {
|
||||||
|
values: [8, 14, 12, 24, 18, 30],
|
||||||
|
severity: "ok",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Sparkline>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
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