Compare commits
37 commits
codex/dash
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2c998b894 | |||
|
|
799299efca | ||
|
|
6405254e42 | ||
| 8b0ef39cdf | |||
|
|
d00a7b3b80 | ||
| da9a5a942d | |||
|
|
16c69a1065 | ||
| b087e65978 | |||
|
|
35cb1574b4 | ||
|
|
d61d296ea1 | ||
|
|
006c7e041f | ||
|
|
a010520a94 | ||
|
|
7e4d13a85c | ||
|
|
96fe4d26c7 | ||
| 29077923e8 | |||
|
|
3188d8b905 | ||
| 216545b838 | |||
|
|
41fb964ebb | ||
| a66f1fdd57 | |||
|
|
a7d0a4c3b2 | ||
|
|
de87464ef1 | ||
| 63b008095f | |||
|
|
0d27920a78 | ||
|
|
328f6e00bf | ||
| b6de67f081 | |||
|
|
a071df3194 | ||
| 30bff9da37 | |||
|
|
bac809952a | ||
|
|
88c448258d | ||
|
|
4ce42fb52b | ||
| 243d71686d | |||
|
|
5e66367687 | ||
| 9aa30b07db | |||
|
|
bc1f64d566 | ||
| cda40401ca | |||
|
|
fd0559a143 | ||
| 83c2c0f4a4 |
22 changed files with 4089 additions and 91 deletions
85
.forgejo/workflows/dimensionlab-website.yml
Normal file
85
.forgejo/workflows/dimensionlab-website.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
name: Dimension Lab website
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dimensionlab-website-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Initialize submodules
|
||||||
|
run: |
|
||||||
|
git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/"
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
|
||||||
|
"$HOME/.bun/bin/bun" --version
|
||||||
|
|
||||||
|
- name: Check, test, and build
|
||||||
|
run: |
|
||||||
|
export BUN_INSTALL="$HOME/.bun"
|
||||||
|
export PATH="$BUN_INSTALL/bin:$PATH"
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run check
|
||||||
|
bun run test
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: ci
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: deploy
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
if [ -d .git ]; then
|
||||||
|
git remote set-url origin git@git.dimensionlab.net:vince/dimensionlab-website.git
|
||||||
|
else
|
||||||
|
git init
|
||||||
|
git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git
|
||||||
|
fi
|
||||||
|
git fetch --force --prune --depth=1 origin "$GITHUB_SHA"
|
||||||
|
git checkout --force --detach "$GITHUB_SHA"
|
||||||
|
git clean -ffdx
|
||||||
|
|
||||||
|
- name: Initialize submodules
|
||||||
|
run: |
|
||||||
|
git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/"
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
- name: Verify Podman deployment socket
|
||||||
|
run: |
|
||||||
|
command -v podman
|
||||||
|
command -v systemctl
|
||||||
|
unit="$(timeout 15s podman inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')"
|
||||||
|
test "$unit" = "dimensionlab-website.service"
|
||||||
|
|
||||||
|
- name: Deploy production website
|
||||||
|
env:
|
||||||
|
DEPLOY_CONTAINER_CLI: podman
|
||||||
|
DEPLOY_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
DEPLOY_REF: ${{ github.ref }}
|
||||||
|
DEPLOY_RESTART_STRATEGY: quadlet-container
|
||||||
|
DEPLOY_SHA: ${{ github.sha }}
|
||||||
|
run: scripts/deploy-dimensionlab-website.sh
|
||||||
24
README.md
24
README.md
|
|
@ -161,3 +161,27 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -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": "vitest run",
|
"test": "bun --bun vitest run",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "bun --bun 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"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ 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 { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import { AppStateView, dashboardHydrationTiles } from "./App";
|
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", () => {
|
||||||
|
|
@ -82,4 +87,152 @@ describe("React app dashboard state view", () => {
|
||||||
id: "auto-refresh",
|
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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,21 @@ import type {
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
|
collectVisibleDashboardModelIds,
|
||||||
|
createDashboardPerformanceMarks,
|
||||||
|
createDashboardRefreshDelay,
|
||||||
|
createDashboardTileSnapshotStore,
|
||||||
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
|
isPersistableDashboardTileSnapshot,
|
||||||
|
runViewportAwareDashboardHydrationQueue,
|
||||||
shouldPauseDashboardRefresh,
|
shouldPauseDashboardRefresh,
|
||||||
|
subscribeToDashboardTileEvents,
|
||||||
|
waitForDashboardHydrationIdle,
|
||||||
|
type DashboardIntersectionObserverFactory,
|
||||||
|
type DashboardTileReference,
|
||||||
|
type DashboardTileSnapshotStoreContext,
|
||||||
|
type RestoredDashboardTileSnapshot,
|
||||||
} from "$lib/client/dashboard-refresh";
|
} from "$lib/client/dashboard-refresh";
|
||||||
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,12 +37,6 @@ import {
|
||||||
type UiDashboardPreview,
|
type UiDashboardPreview,
|
||||||
} from "@dimensionlab/ui";
|
} from "@dimensionlab/ui";
|
||||||
|
|
||||||
type DashboardTileReference =
|
|
||||||
| { kind: "telemetry"; id: string }
|
|
||||||
| { kind: "service"; groupId: string; id: string }
|
|
||||||
| { kind: "module"; id: string }
|
|
||||||
| { kind: "status"; stripId: string; id: string };
|
|
||||||
|
|
||||||
type DashboardTileResponse =
|
type DashboardTileResponse =
|
||||||
| {
|
| {
|
||||||
state: "ready";
|
state: "ready";
|
||||||
|
|
@ -40,8 +47,25 @@ type DashboardTileResponse =
|
||||||
state: "not_found";
|
state: "not_found";
|
||||||
tile: DashboardTileReference;
|
tile: DashboardTileReference;
|
||||||
message: string;
|
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,
|
||||||
|
|
@ -155,8 +179,16 @@ 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;
|
let hydrationRun = 0;
|
||||||
const requestAborter = createDashboardRequestAborter();
|
const requestAborter = createDashboardRequestAborter();
|
||||||
|
const performanceMarks = createDashboardPerformanceMarks();
|
||||||
|
const refreshDelay = createDashboardRefreshDelay();
|
||||||
|
const tileBackoff = createDashboardTileBackoff();
|
||||||
|
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
||||||
|
getTileSnapshotStorage(),
|
||||||
|
);
|
||||||
|
|
||||||
function refreshPaused() {
|
function refreshPaused() {
|
||||||
return shouldPauseDashboardRefresh({
|
return shouldPauseDashboardRefresh({
|
||||||
|
|
@ -166,14 +198,26 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefreshTimer() {
|
function clearRefreshTimer() {
|
||||||
if (refreshTimer) {
|
if (refreshTimer !== undefined) {
|
||||||
window.clearInterval(refreshTimer);
|
window.clearTimeout(refreshTimer);
|
||||||
refreshTimer = undefined;
|
refreshTimer = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleNextRefresh(baseDelayMs: number) {
|
||||||
|
clearRefreshTimer();
|
||||||
|
if (cancelled || refreshPaused()) return;
|
||||||
|
|
||||||
|
refreshTimer = window.setTimeout(() => {
|
||||||
|
refreshTimer = undefined;
|
||||||
|
void loadDashboard();
|
||||||
|
}, refreshDelay.nextDelayMs(baseDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
function pauseRefreshes() {
|
function pauseRefreshes() {
|
||||||
clearRefreshTimer();
|
clearRefreshTimer();
|
||||||
|
unsubscribeTileEvents?.();
|
||||||
|
unsubscribeTileEvents = undefined;
|
||||||
requestAborter.abortActiveRequests();
|
requestAborter.abortActiveRequests();
|
||||||
setHydratingItemIds(new Set());
|
setHydratingItemIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +225,7 @@ export default function App() {
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
if (cancelled || refreshPaused()) return;
|
if (cancelled || refreshPaused()) return;
|
||||||
|
|
||||||
|
clearRefreshTimer();
|
||||||
const shellSignal = requestAborter.beginShellRun();
|
const shellSignal = requestAborter.beginShellRun();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -188,37 +233,70 @@ export default function App() {
|
||||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||||
|
|
||||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
||||||
setDashboard(nextDashboard);
|
refreshDelay.recordSuccess();
|
||||||
|
performanceMarks.markShellLoad();
|
||||||
|
const restored = restoreDashboardTileSnapshots(
|
||||||
|
nextDashboard,
|
||||||
|
nextDashboard.state === "ready"
|
||||||
|
? tileSnapshotStore.restore({
|
||||||
|
currentRevisionId: nextDashboard.currentRevisionId,
|
||||||
|
schemaVersion: nextDashboard.schemaVersion,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
setDashboard(restored.dashboard);
|
||||||
|
|
||||||
const currentRun = ++hydrationRun;
|
const currentRun = ++hydrationRun;
|
||||||
if (
|
if (
|
||||||
nextDashboard.state === "ready" &&
|
restored.dashboard.state === "ready" &&
|
||||||
nextDashboard.liveDatasourceHydration?.enabled !== false
|
restored.dashboard.liveDatasourceHydration?.enabled !== false
|
||||||
) {
|
) {
|
||||||
const tiles = dashboardHydrationTiles(nextDashboard.document);
|
const tiles = dashboardHydrationTiles(restored.dashboard.document).filter(
|
||||||
|
(tile) => tileBackoff.canAttempt(dashboardTileKey(tile)),
|
||||||
|
);
|
||||||
const tileSignal = requestAborter.beginTileRun();
|
const tileSignal = requestAborter.beginTileRun();
|
||||||
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
setHydratingItemIds(
|
||||||
hydrateDashboardTiles(tiles, currentRun, tileSignal);
|
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 {
|
} else {
|
||||||
setHydratingItemIds(new Set());
|
setHydratingItemIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
clearRefreshTimer();
|
|
||||||
|
|
||||||
const refreshIntervalSeconds =
|
const refreshIntervalSeconds =
|
||||||
nextDashboard.state === "ready"
|
nextDashboard.state === "ready"
|
||||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (refreshIntervalSeconds && !refreshPaused()) {
|
if (refreshIntervalSeconds && !refreshPaused()) {
|
||||||
refreshTimer = window.setInterval(
|
lastRefreshIntervalMs = refreshIntervalSeconds * 1000;
|
||||||
() => void loadDashboard(),
|
scheduleNextRefresh(lastRefreshIntervalMs);
|
||||||
refreshIntervalSeconds * 1000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAbortError(error) && !cancelled) {
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
refreshDelay.recordFailure();
|
||||||
console.error("Dashboard refresh failed", error);
|
console.error("Dashboard refresh failed", error);
|
||||||
|
scheduleNextRefresh(lastRefreshIntervalMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -227,31 +305,218 @@ export default function App() {
|
||||||
tiles: DashboardTileReference[],
|
tiles: DashboardTileReference[],
|
||||||
run: number,
|
run: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||||
) {
|
) {
|
||||||
tiles.forEach((tile) => {
|
const modelIds = tiles.map(dashboardTileModelId);
|
||||||
void hydrateDashboardTile(tile, run, signal);
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateDashboardTile(
|
function subscribeDashboardTileEvents(
|
||||||
tile: DashboardTileReference,
|
|
||||||
run: number,
|
run: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||||
) {
|
) {
|
||||||
const key = dashboardTileKey(tile);
|
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 {
|
try {
|
||||||
const response = await fetch(dashboardTileUrl(tile), { signal });
|
const response = await fetch("/api/dashboard/tiles", {
|
||||||
const tileResponse = (await response.json()) as DashboardTileResponse;
|
body: JSON.stringify({ tiles }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const batchResponse = (await response.json()) as DashboardTilesBatchResponse;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
signal.aborted ||
|
signal.aborted ||
|
||||||
run !== hydrationRun ||
|
run !== hydrationRun ||
|
||||||
!response.ok ||
|
!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"
|
tileResponse.state !== "ready"
|
||||||
) {
|
) {
|
||||||
return;
|
return signal.aborted || cancelled || run !== hydrationRun
|
||||||
|
? "aborted"
|
||||||
|
: "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyTileResponse = tileResponse;
|
const readyTileResponse = tileResponse;
|
||||||
|
|
@ -263,11 +528,19 @@ export default function App() {
|
||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
tileSnapshotStore.saveReadyTile({
|
||||||
if (!isAbortError(error) && !cancelled) {
|
...snapshotContext,
|
||||||
console.error("Dashboard tile hydration failed", error);
|
response: readyTileResponse,
|
||||||
|
});
|
||||||
|
performanceMarks.markFirstTileReady();
|
||||||
|
return "ready";
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
|
function finishDashboardTileHydration(
|
||||||
|
key: string,
|
||||||
|
run: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) {
|
||||||
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
||||||
setHydratingItemIds((current) => {
|
setHydratingItemIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
|
|
@ -276,7 +549,6 @@ export default function App() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const detachRefreshLifecycle = attachDashboardRefreshLifecycle({
|
const detachRefreshLifecycle = attachDashboardRefreshLifecycle({
|
||||||
documentTarget: document,
|
documentTarget: document,
|
||||||
|
|
@ -291,6 +563,7 @@ export default function App() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearRefreshTimer();
|
clearRefreshTimer();
|
||||||
|
unsubscribeTileEvents?.();
|
||||||
requestAborter.abortActiveRequests();
|
requestAborter.abortActiveRequests();
|
||||||
detachRefreshLifecycle();
|
detachRefreshLifecycle();
|
||||||
};
|
};
|
||||||
|
|
@ -326,6 +599,61 @@ function getThemeStorage(): Storage | 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 {
|
function isAbortError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
|
|
@ -335,6 +663,57 @@ function isAbortError(error: unknown): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function markHydratingItems(
|
||||||
dashboard: UiDashboardPreview,
|
dashboard: UiDashboardPreview,
|
||||||
hydratingItemIds?: ReadonlySet<string>,
|
hydratingItemIds?: ReadonlySet<string>,
|
||||||
|
|
@ -415,7 +794,7 @@ export function dashboardHydrationTiles(
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [...telemetry, ...services, ...modules, ...status];
|
return [...status, ...telemetry, ...modules, ...services];
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashboardTileKey(tile: DashboardTileReference): string {
|
function dashboardTileKey(tile: DashboardTileReference): string {
|
||||||
|
|
@ -424,6 +803,15 @@ function dashboardTileKey(tile: DashboardTileReference): string {
|
||||||
return `${tile.kind}:${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 {
|
function dashboardTileUrl(tile: DashboardTileReference): string {
|
||||||
const parts = tile.kind === "status"
|
const parts = tile.kind === "status"
|
||||||
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
|
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
|
collectVisibleDashboardModelIds,
|
||||||
|
createDashboardPerformanceMarks,
|
||||||
|
createDashboardRefreshDelay,
|
||||||
|
dashboardPerformanceMarks,
|
||||||
|
createDashboardTileSnapshotStore,
|
||||||
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
|
runDashboardHydrationQueue,
|
||||||
|
runDashboardHydrationBatchQueue,
|
||||||
|
runViewportAwareDashboardHydrationQueue,
|
||||||
shouldPauseDashboardRefresh,
|
shouldPauseDashboardRefresh,
|
||||||
|
splitDashboardHydrationItemsByVisibility,
|
||||||
|
subscribeToDashboardTileEvents,
|
||||||
|
waitForDashboardHydrationIdle,
|
||||||
|
type DashboardIntersectionEntry,
|
||||||
} from "./dashboard-refresh";
|
} from "./dashboard-refresh";
|
||||||
|
|
||||||
describe("dashboard refresh lifecycle", () => {
|
describe("dashboard refresh lifecycle", () => {
|
||||||
|
|
@ -73,4 +86,548 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
|
|
||||||
expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]);
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,39 @@ export interface DashboardRefreshPauseState {
|
||||||
online: boolean;
|
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(
|
export function shouldPauseDashboardRefresh(
|
||||||
state: DashboardRefreshPauseState,
|
state: DashboardRefreshPauseState,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
@ -88,3 +121,766 @@ export function attachDashboardRefreshLifecycle(
|
||||||
options.windowTarget.removeEventListener("pagehide", handlePageHide);
|
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,7 +128,6 @@ const verifiedSeedIconIds = new Set([
|
||||||
"mdi:pulse",
|
"mdi:pulse",
|
||||||
"mdi:robot-outline",
|
"mdi:robot-outline",
|
||||||
"mdi:router-network",
|
"mdi:router-network",
|
||||||
"mdi:text-box-search",
|
|
||||||
"mdi:thermometer",
|
"mdi:thermometer",
|
||||||
"mdi:web",
|
"mdi:web",
|
||||||
"mdi:weather-sunny",
|
"mdi:weather-sunny",
|
||||||
|
|
|
||||||
|
|
@ -431,14 +431,6 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
|
||||||
href: "https://models.dimensionlab.net",
|
href: "https://models.dimensionlab.net",
|
||||||
datasource: uptimeMonitor(7),
|
datasource: uptimeMonitor(7),
|
||||||
}),
|
}),
|
||||||
service({
|
|
||||||
id: "prompt-registry",
|
|
||||||
label: "Prompt Registry",
|
|
||||||
description: "Shared prompts, traces, evals",
|
|
||||||
icon: "mdi:text-box-search",
|
|
||||||
href: "https://prompts.dimensionlab.net",
|
|
||||||
datasource: uptimeMonitor(20),
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
group("systems", "Systems", [
|
group("systems", "Systems", [
|
||||||
service({
|
service({
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,6 @@ describe("dashboard runtime loader", () => {
|
||||||
expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain(
|
expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain(
|
||||||
"auto-refresh",
|
"auto-refresh",
|
||||||
);
|
);
|
||||||
expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain(
|
|
||||||
"prompt-registry",
|
|
||||||
);
|
|
||||||
expect(store.listRevisions()).toHaveLength(2);
|
expect(store.listRevisions()).toHaveLength(2);
|
||||||
expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed");
|
expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed");
|
||||||
});
|
});
|
||||||
|
|
@ -138,13 +135,5 @@ function olderDimensionLabSeed() {
|
||||||
...strip,
|
...strip,
|
||||||
items: strip.items.filter((item) => item.id !== "auto-refresh"),
|
items: strip.items.filter((item) => item.id !== "auto-refresh"),
|
||||||
}));
|
}));
|
||||||
document.serviceGroups = document.serviceGroups.map((group) =>
|
|
||||||
group.id === "ai-automation"
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
services: group.services.filter((service) => service.id !== "prompt-registry"),
|
|
||||||
}
|
|
||||||
: group,
|
|
||||||
);
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 } from ".";
|
import { resolveDashboardDatasources, resolveDashboardTile } from ".";
|
||||||
|
|
||||||
describe("dashboard datasource resolution", () => {
|
describe("dashboard datasource resolution", () => {
|
||||||
test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => {
|
test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => {
|
||||||
|
|
@ -124,6 +124,100 @@ describe("dashboard datasource resolution", () => {
|
||||||
expect(resolved).not.toBe(testDocument);
|
expect(resolved).not.toBe(testDocument);
|
||||||
expect(testDocument.telemetry[0].value.value).toBe(1);
|
expect(testDocument.telemetry[0].value.value).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shares service health snapshots across aggregate tile hydration", async () => {
|
||||||
|
let resolveFetch: ((response: Response) => void) | undefined;
|
||||||
|
const fetch = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url !== "https://service.example/health") {
|
||||||
|
throw new Error(`Unhandled test request: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const document = testDashboard();
|
||||||
|
|
||||||
|
const moduleTile = resolveDashboardTile(
|
||||||
|
document,
|
||||||
|
{ kind: "module", id: "runtime-health-summary" },
|
||||||
|
{ fetch },
|
||||||
|
);
|
||||||
|
const statusTile = resolveDashboardTile(
|
||||||
|
document,
|
||||||
|
{ kind: "status", stripId: "footer", id: "system-status" },
|
||||||
|
{ fetch },
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFetch?.(jsonResponse({ status: "UP", ping: 42 }));
|
||||||
|
expect(await moduleTile).toMatchObject({
|
||||||
|
state: "ready",
|
||||||
|
item: {
|
||||||
|
id: "runtime-health-summary",
|
||||||
|
severity: "ok",
|
||||||
|
value: "all systems operational",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await statusTile).toMatchObject({
|
||||||
|
state: "ready",
|
||||||
|
item: {
|
||||||
|
id: "system-status",
|
||||||
|
severity: "ok",
|
||||||
|
value: "All systems operational",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isolates service health snapshots by datasource fetch context", async () => {
|
||||||
|
const firstFetch = vi.fn(async () =>
|
||||||
|
jsonResponse({
|
||||||
|
status: "UP",
|
||||||
|
ping: 42,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const secondFetch = vi.fn(async () =>
|
||||||
|
jsonResponse({
|
||||||
|
status: "DOWN",
|
||||||
|
ping: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const document = testDashboard();
|
||||||
|
document.serviceGroups[0].services[0] = {
|
||||||
|
...document.serviceGroups[0].services[0],
|
||||||
|
id: "api-isolated",
|
||||||
|
datasource: {
|
||||||
|
type: "external",
|
||||||
|
adapter: "http-status",
|
||||||
|
reference: "GET https://service.example/isolated",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await resolveDashboardTile(
|
||||||
|
document,
|
||||||
|
{ kind: "status", stripId: "footer", id: "system-status" },
|
||||||
|
{ fetch: firstFetch },
|
||||||
|
);
|
||||||
|
const second = await resolveDashboardTile(
|
||||||
|
document,
|
||||||
|
{ kind: "status", stripId: "footer", id: "system-status" },
|
||||||
|
{ fetch: secondFetch },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(secondFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toMatchObject({
|
||||||
|
state: "ready",
|
||||||
|
item: {
|
||||||
|
id: "system-status",
|
||||||
|
severity: "danger",
|
||||||
|
value: "1 service down",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const testDocument = testDashboard();
|
const testDocument = testDashboard();
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export type DashboardTileResolution =
|
||||||
|
|
||||||
export interface DatasourceResolutionOptions {
|
export interface DatasourceResolutionOptions {
|
||||||
fetch?: DatasourceFetch;
|
fetch?: DatasourceFetch;
|
||||||
|
now?: () => number;
|
||||||
prometheusBaseUrl?: string;
|
prometheusBaseUrl?: string;
|
||||||
prometheusRangeSeconds?: number;
|
prometheusRangeSeconds?: number;
|
||||||
prometheusStepSeconds?: number;
|
prometheusStepSeconds?: number;
|
||||||
|
|
@ -113,9 +114,7 @@ export async function resolveDashboardTile(
|
||||||
const item = module.id === "runtime-health-summary"
|
const item = module.id === "runtime-health-summary"
|
||||||
? runtimeHealthSummary(
|
? runtimeHealthSummary(
|
||||||
module,
|
module,
|
||||||
await Promise.all(
|
await serviceGroupsSnapshot(document, context),
|
||||||
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: await resolveModule(module, context);
|
: await resolveModule(module, context);
|
||||||
|
|
||||||
|
|
@ -132,7 +131,7 @@ export async function resolveDashboardTile(
|
||||||
item: await resolveStatusTile(
|
item: await resolveStatusTile(
|
||||||
statusItem,
|
statusItem,
|
||||||
document.metadata.refreshIntervalSeconds,
|
document.metadata.refreshIntervalSeconds,
|
||||||
document.serviceGroups,
|
document,
|
||||||
context,
|
context,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -140,14 +139,27 @@ export async function resolveDashboardTile(
|
||||||
|
|
||||||
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];
|
||||||
|
|
@ -159,8 +171,12 @@ interface PrometheusMatrixResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
||||||
|
const fetch = options.fetch || globalThis.fetch;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: options.fetch || globalThis.fetch,
|
fetch,
|
||||||
|
fetchIdentity: datasourceFetchIdentity(fetch),
|
||||||
|
now: options.now || Date.now,
|
||||||
prometheusBaseUrl:
|
prometheusBaseUrl:
|
||||||
options.prometheusBaseUrl ||
|
options.prometheusBaseUrl ||
|
||||||
process.env.PROMETHEUS_BASE_URL ||
|
process.env.PROMETHEUS_BASE_URL ||
|
||||||
|
|
@ -171,6 +187,68 @@ function datasourceContext(options: DatasourceResolutionOptions): DatasourceCont
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function datasourceFetchIdentity(fetch: DatasourceFetch): number {
|
||||||
|
const existing = datasourceFetchIdentities.get(fetch);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const next = nextDatasourceFetchIdentity;
|
||||||
|
nextDatasourceFetchIdentity += 1;
|
||||||
|
datasourceFetchIdentities.set(fetch, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceGroupsSnapshot(
|
||||||
|
document: DashboardDocument,
|
||||||
|
context: DatasourceContext,
|
||||||
|
): Promise<ServiceGroup[]> {
|
||||||
|
if (context.serviceGroupsSnapshot) return context.serviceGroupsSnapshot;
|
||||||
|
|
||||||
|
const key = serviceGroupsSnapshotKey(document, context);
|
||||||
|
const now = context.now();
|
||||||
|
const cached = serviceGroupsSnapshotCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
context.serviceGroupsSnapshot = cached.snapshot;
|
||||||
|
return cached.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = Promise.all(
|
||||||
|
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
||||||
|
);
|
||||||
|
context.serviceGroupsSnapshot = snapshot;
|
||||||
|
serviceGroupsSnapshotCache.set(key, {
|
||||||
|
expiresAt: now + serviceGroupsSnapshotTtlMs,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
snapshot.catch(() => {
|
||||||
|
if (serviceGroupsSnapshotCache.get(key)?.snapshot === snapshot) {
|
||||||
|
serviceGroupsSnapshotCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceGroupsSnapshotKey(
|
||||||
|
document: DashboardDocument,
|
||||||
|
context: DatasourceContext,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
fetchIdentity: context.fetchIdentity,
|
||||||
|
prometheusBaseUrl: context.prometheusBaseUrl,
|
||||||
|
prometheusRangeSeconds: context.prometheusRangeSeconds,
|
||||||
|
prometheusStepSeconds: context.prometheusStepSeconds,
|
||||||
|
requestTimeoutMs: context.requestTimeoutMs,
|
||||||
|
serviceGroups: document.serviceGroups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
services: group.services.map((service) => ({
|
||||||
|
datasource: service.datasource,
|
||||||
|
id: service.id,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTelemetryCard(
|
async function resolveTelemetryCard(
|
||||||
card: TelemetryCard,
|
card: TelemetryCard,
|
||||||
context: DatasourceContext,
|
context: DatasourceContext,
|
||||||
|
|
@ -485,13 +563,11 @@ function resolveStatusItem(
|
||||||
async function resolveStatusTile(
|
async function resolveStatusTile(
|
||||||
item: StatusItem,
|
item: StatusItem,
|
||||||
refreshIntervalSeconds: number | undefined,
|
refreshIntervalSeconds: number | undefined,
|
||||||
serviceGroups: ServiceGroup[],
|
document: DashboardDocument,
|
||||||
context: DatasourceContext,
|
context: DatasourceContext,
|
||||||
): Promise<StatusItem> {
|
): Promise<StatusItem> {
|
||||||
if (item.id === "system-status") {
|
if (item.id === "system-status") {
|
||||||
const resolvedGroups = await Promise.all(
|
const resolvedGroups = await serviceGroupsSnapshot(document, context);
|
||||||
serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
|
||||||
);
|
|
||||||
const health = serviceHealthSummary(resolvedGroups);
|
const health = serviceHealthSummary(resolvedGroups);
|
||||||
return {
|
return {
|
||||||
...structuredClone(item),
|
...structuredClone(item),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
|
@ -42,6 +44,8 @@ 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"]),
|
||||||
|
|
@ -253,6 +257,186 @@ 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 =
|
||||||
|
|
@ -269,3 +453,126 @@ 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
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
|
||||||
77
apps/web/src/server/index.test.ts
Normal file
77
apps/web/src/server/index.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { afterAll, describe, expect, test, vi } from "vitest";
|
||||||
|
import { handleRequest } from "./index";
|
||||||
|
|
||||||
|
describe("server request routing", () => {
|
||||||
|
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
consoleInfo.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes dashboard tile batch requests", async () => {
|
||||||
|
const response = await handleRequest(
|
||||||
|
new Request("https://example.test/api/dashboard/tiles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
tiles: [
|
||||||
|
{
|
||||||
|
kind: "status",
|
||||||
|
stripId: "footer-status",
|
||||||
|
id: "auto-refresh",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
state: "ready",
|
||||||
|
tiles: [
|
||||||
|
{
|
||||||
|
state: "ready",
|
||||||
|
tile: {
|
||||||
|
kind: "status",
|
||||||
|
stripId: "footer-status",
|
||||||
|
id: "auto-refresh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-post dashboard tile batch requests", async () => {
|
||||||
|
const response = await handleRequest(
|
||||||
|
new Request("https://example.test/api/dashboard/tiles", {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes dashboard event stream requests", async () => {
|
||||||
|
const response = await handleRequest(
|
||||||
|
new Request("https://example.test/api/dashboard/events", {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe(
|
||||||
|
"text/event-stream; charset=utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-get dashboard event stream requests", async () => {
|
||||||
|
const response = await handleRequest(
|
||||||
|
new Request("https://example.test/api/dashboard/events", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
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 { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard";
|
import {
|
||||||
|
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);
|
||||||
|
|
@ -23,6 +28,16 @@ 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 (url.pathname.startsWith("/api/dashboard/tile/")) {
|
||||||
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
||||||
return handleDashboardTileRoute(url.pathname);
|
return handleDashboardTileRoute(url.pathname);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { afterAll, afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import { loadDashboardResponse, loadDashboardTileResponse } from "./dashboard";
|
import {
|
||||||
|
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);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleInfo.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
consoleInfo.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
||||||
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||||
new Error("live datasource fetch should not run for the shell response"),
|
new Error("live datasource fetch should not run for the shell response"),
|
||||||
|
|
@ -145,6 +165,555 @@ describe("dashboard API route", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("does not hydrate tile routes when live datasources are disabled", async () => {
|
||||||
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
||||||
process.env.DISABLE_LIVE_DATASOURCES = "1";
|
process.env.DISABLE_LIVE_DATASOURCES = "1";
|
||||||
|
|
@ -179,3 +748,52 @@ function jsonResponse(payload: unknown): Response {
|
||||||
headers: { "content-type": "application/json" },
|
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,3 +1,4 @@
|
||||||
|
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
|
||||||
import {
|
import {
|
||||||
loadDashboardRuntime,
|
loadDashboardRuntime,
|
||||||
type DashboardRuntimeOptions,
|
type DashboardRuntimeOptions,
|
||||||
|
|
@ -19,6 +20,111 @@ export interface LoadDashboardResponseOptions
|
||||||
DatasourceResolutionOptions {
|
DatasourceResolutionOptions {
|
||||||
disableLiveDatasources?: boolean;
|
disableLiveDatasources?: boolean;
|
||||||
hydrateLiveDatasources?: 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(
|
||||||
|
|
@ -67,13 +173,26 @@ export async function loadDashboardTileResponse(
|
||||||
options.disableLiveDatasources ||
|
options.disableLiveDatasources ||
|
||||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
||||||
) {
|
) {
|
||||||
return {
|
const tileKey = dashboardTileCacheKey(tile);
|
||||||
|
const startedAt = options.now?.() ?? Date.now();
|
||||||
|
const response = {
|
||||||
state: "disabled",
|
state: "disabled",
|
||||||
tile,
|
tile,
|
||||||
message: "Live datasource hydration is disabled.",
|
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, {
|
const dashboard = loadDashboardRuntime(undefined, {
|
||||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||||
|
|
@ -81,21 +200,71 @@ export async function loadDashboardTileResponse(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dashboard.state !== "ready") {
|
if (dashboard.state !== "ready") {
|
||||||
return {
|
const response = {
|
||||||
state: "not_found",
|
state: "not_found",
|
||||||
tile,
|
tile,
|
||||||
message: `Dashboard is not ready: ${dashboard.state}`,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveDashboardTile(dashboard.document, tile, options);
|
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): Promise<Response> {
|
export async function handleDashboardTileRoute(
|
||||||
|
pathname: string,
|
||||||
|
options: LoadDashboardResponseOptions = {},
|
||||||
|
): Promise<Response> {
|
||||||
const tile = parseDashboardTilePath(pathname);
|
const tile = parseDashboardTilePath(pathname);
|
||||||
if (!tile) {
|
if (!tile) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|
@ -104,12 +273,310 @@ export async function handleDashboardTileRoute(pathname: string): Promise<Respon
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await loadDashboardTileResponse(tile);
|
const response = await loadDashboardTileResponse(tile, options);
|
||||||
return Response.json(response, {
|
return Response.json(response, {
|
||||||
status: response.state === "not_found" ? 404 : 200,
|
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 {
|
function parseDashboardTilePath(pathname: string): DashboardTileReference | null {
|
||||||
const parts = pathname.split("/").filter(Boolean);
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
const [, dashboard, tileRoot, kind, firstId, secondId] = parts;
|
const [, dashboard, tileRoot, kind, firstId, secondId] = parts;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ const linkedServiceIds = [
|
||||||
"open-webui",
|
"open-webui",
|
||||||
"comfyui",
|
"comfyui",
|
||||||
"models",
|
"models",
|
||||||
"prompt-registry",
|
|
||||||
"adminer",
|
"adminer",
|
||||||
"assistant",
|
"assistant",
|
||||||
"suna",
|
"suna",
|
||||||
|
|
@ -104,7 +103,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(28);
|
expect(metrics.serviceRowCount).toBe(27);
|
||||||
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: 304 KiB After Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 451 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 302 KiB |
362
scripts/deploy-dimensionlab-website.sh
Executable file
362
scripts/deploy-dimensionlab-website.sh
Executable file
|
|
@ -0,0 +1,362 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_NAME="${APP_NAME:-dimensionlab-website}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}"
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}"
|
||||||
|
IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}"
|
||||||
|
CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}"
|
||||||
|
PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}"
|
||||||
|
TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}"
|
||||||
|
DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}"
|
||||||
|
DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}"
|
||||||
|
DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}"
|
||||||
|
|
||||||
|
dry_run=false
|
||||||
|
rollback_tag=""
|
||||||
|
release_tag=""
|
||||||
|
latest_tag="${IMAGE_REPO}:latest"
|
||||||
|
container_cli=""
|
||||||
|
deployment_started=false
|
||||||
|
rollback_done=false
|
||||||
|
rollback_in_progress=false
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: $0 [--dry-run]
|
||||||
|
|
||||||
|
Build and deploy ${APP_NAME} for the production ${SERVICE_NAME} unit.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Print the deployment actions without changing the host.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[deploy:%s] %s\n' "$APP_NAME" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '[deploy:%s] ERROR: %s\n' "$APP_NAME" "$*" >&2
|
||||||
|
if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then
|
||||||
|
rollback || true
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if "$dry_run"; then
|
||||||
|
printf '[deploy:%s] DRY-RUN:' "$APP_NAME"
|
||||||
|
printf ' %q' "$@"
|
||||||
|
printf '\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run)
|
||||||
|
dry_run=true
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
fail "unknown argument: $arg"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
deployment_ref() {
|
||||||
|
printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment_event() {
|
||||||
|
printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_main_push() {
|
||||||
|
local event
|
||||||
|
local ref
|
||||||
|
|
||||||
|
event="$(deployment_event)"
|
||||||
|
ref="$(deployment_ref)"
|
||||||
|
|
||||||
|
if [ -n "$event" ] && [ "$event" != "push" ]; then
|
||||||
|
fail "refusing to deploy for event '$event'; production deploys only run for push"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ref" ]; then
|
||||||
|
[ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch="$(git branch --show-current 2>/dev/null || true)"
|
||||||
|
[ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_container_cli() {
|
||||||
|
if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then
|
||||||
|
command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI"
|
||||||
|
container_cli="$DEPLOY_CONTAINER_CLI"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
container_cli="podman"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
container_cli="docker"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "podman or docker is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
current_sha() {
|
||||||
|
if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then
|
||||||
|
printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git rev-parse HEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
tag_existing_latest_for_rollback() {
|
||||||
|
rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then
|
||||||
|
log "tagging current latest image as $rollback_tag"
|
||||||
|
run "$container_cli" tag "$latest_tag" "$rollback_tag"
|
||||||
|
else
|
||||||
|
log "no existing $latest_tag image found; rollback image tag will not be created"
|
||||||
|
rollback_tag=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
container_systemd_unit() {
|
||||||
|
"$container_cli" inspect "$CONTAINER_NAME" \
|
||||||
|
--format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
require_container_managed_by_service() {
|
||||||
|
local unit
|
||||||
|
|
||||||
|
if "$dry_run"; then
|
||||||
|
log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
unit="$(container_systemd_unit)"
|
||||||
|
[ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_restart_strategy() {
|
||||||
|
case "$DEPLOY_RESTART_STRATEGY" in
|
||||||
|
systemctl)
|
||||||
|
if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||||
|
fail "systemctl --user cannot access $SERVICE_NAME"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
quadlet-container|kill-container)
|
||||||
|
require_container_managed_by_service
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
if "$dry_run"; then
|
||||||
|
log "DRY-RUN: would validate automatic restart strategy"
|
||||||
|
elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||||
|
require_container_managed_by_service
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize_submodules() {
|
||||||
|
log "initializing submodules"
|
||||||
|
run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/"
|
||||||
|
run git submodule update --init --recursive
|
||||||
|
}
|
||||||
|
|
||||||
|
build_image() {
|
||||||
|
local sha
|
||||||
|
local short_sha
|
||||||
|
|
||||||
|
sha="$(current_sha)"
|
||||||
|
short_sha="${sha:0:12}"
|
||||||
|
release_tag="${IMAGE_REPO}:${short_sha}"
|
||||||
|
|
||||||
|
[ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE"
|
||||||
|
|
||||||
|
log "building $release_tag and $latest_tag from $CONTAINERFILE"
|
||||||
|
run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" .
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_service() {
|
||||||
|
log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY"
|
||||||
|
|
||||||
|
case "$DEPLOY_RESTART_STRATEGY" in
|
||||||
|
systemctl)
|
||||||
|
run systemctl --user restart "$SERVICE_NAME"
|
||||||
|
;;
|
||||||
|
quadlet-container|kill-container)
|
||||||
|
require_container_managed_by_service
|
||||||
|
run "$container_cli" stop "$CONTAINER_NAME"
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||||
|
run systemctl --user restart "$SERVICE_NAME"
|
||||||
|
else
|
||||||
|
require_container_managed_by_service
|
||||||
|
run "$container_cli" stop "$CONTAINER_NAME"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_image_id() {
|
||||||
|
"$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
container_image_id() {
|
||||||
|
"$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
container_running() {
|
||||||
|
local running
|
||||||
|
running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)"
|
||||||
|
[ "$running" = "true" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_container_restart() {
|
||||||
|
local expected_image
|
||||||
|
|
||||||
|
if "$dry_run"; then
|
||||||
|
log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
expected_image="$(latest_image_id)"
|
||||||
|
[ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag"
|
||||||
|
|
||||||
|
wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_container_image() {
|
||||||
|
local expected_image="$1"
|
||||||
|
local label="$2"
|
||||||
|
local deadline
|
||||||
|
|
||||||
|
deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS))
|
||||||
|
|
||||||
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
|
if container_running && [ "$(container_image_id)" = "$expected_image" ]; then
|
||||||
|
log "$CONTAINER_NAME is running the $label"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_get() {
|
||||||
|
local url="$1"
|
||||||
|
curl -fsS --max-time 10 -o /dev/null "$url"
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_tiles() {
|
||||||
|
local response
|
||||||
|
|
||||||
|
response="$(
|
||||||
|
curl -fsS --max-time 20 \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
--data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \
|
||||||
|
"$TILE_BATCH_URL"
|
||||||
|
)"
|
||||||
|
|
||||||
|
[[ "$response" == *'"state":"ready"'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_smoke() {
|
||||||
|
local deadline
|
||||||
|
|
||||||
|
if "$dry_run"; then
|
||||||
|
log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS))
|
||||||
|
until smoke_get "$PUBLIC_URL" && smoke_tiles; do
|
||||||
|
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||||
|
fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
log "smoke checks passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
local rollback_image
|
||||||
|
|
||||||
|
if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rollback_done=true
|
||||||
|
rollback_in_progress=true
|
||||||
|
printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2
|
||||||
|
rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)"
|
||||||
|
"$container_cli" tag "$rollback_tag" "$latest_tag" || true
|
||||||
|
if container_running; then
|
||||||
|
restart_service || true
|
||||||
|
else
|
||||||
|
printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2
|
||||||
|
fi
|
||||||
|
if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then
|
||||||
|
printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2
|
||||||
|
else
|
||||||
|
printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2
|
||||||
|
fi
|
||||||
|
rollback_in_progress=false
|
||||||
|
}
|
||||||
|
|
||||||
|
on_error() {
|
||||||
|
local status=$?
|
||||||
|
rollback
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap on_error ERR
|
||||||
|
|
||||||
|
require_main_push
|
||||||
|
select_container_cli
|
||||||
|
validate_restart_strategy
|
||||||
|
|
||||||
|
log "using container CLI: $container_cli"
|
||||||
|
log "target image: $latest_tag"
|
||||||
|
log "target service: $SERVICE_NAME"
|
||||||
|
|
||||||
|
initialize_submodules
|
||||||
|
tag_existing_latest_for_rollback
|
||||||
|
build_image
|
||||||
|
deployment_started=true
|
||||||
|
restart_service
|
||||||
|
wait_for_container_restart
|
||||||
|
wait_for_smoke
|
||||||
|
|
||||||
|
log "deployment finished"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue