Compare commits
No commits in common. "30bff9da37330edb8010fce04bd84b9a2f8d06e8" and "243d71686dc836f6dcd50536eaa1e93908c25d6f" have entirely different histories.
30bff9da37
...
243d71686d
4 changed files with 8 additions and 638 deletions
|
|
@ -2,11 +2,7 @@ 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 {
|
import { AppStateView, dashboardHydrationTiles } from "./App";
|
||||||
AppStateView,
|
|
||||||
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", () => {
|
||||||
|
|
@ -124,39 +120,4 @@ describe("React app dashboard state view", () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,10 @@ import type {
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
createDashboardTileSnapshotStore,
|
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
runDashboardHydrationQueue,
|
runDashboardHydrationQueue,
|
||||||
shouldPauseDashboardRefresh,
|
shouldPauseDashboardRefresh,
|
||||||
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 {
|
||||||
|
|
@ -45,17 +42,11 @@ type DashboardTileResponse =
|
||||||
state: "not_found";
|
state: "not_found";
|
||||||
tile: DashboardTileReference;
|
tile: DashboardTileReference;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: "disabled";
|
|
||||||
tile: DashboardTileReference;
|
|
||||||
message: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
||||||
|
|
||||||
const dashboardTileHydrationConcurrency = 6;
|
const dashboardTileHydrationConcurrency = 6;
|
||||||
type DashboardTileSnapshotStore = ReturnType<typeof createDashboardTileSnapshotStore>;
|
|
||||||
|
|
||||||
export function AppStateView({
|
export function AppStateView({
|
||||||
dashboard,
|
dashboard,
|
||||||
|
|
@ -173,9 +164,6 @@ export default function App() {
|
||||||
let hydrationRun = 0;
|
let hydrationRun = 0;
|
||||||
const requestAborter = createDashboardRequestAborter();
|
const requestAborter = createDashboardRequestAborter();
|
||||||
const tileBackoff = createDashboardTileBackoff();
|
const tileBackoff = createDashboardTileBackoff();
|
||||||
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
|
||||||
getTileSnapshotStorage(),
|
|
||||||
);
|
|
||||||
|
|
||||||
function refreshPaused() {
|
function refreshPaused() {
|
||||||
return shouldPauseDashboardRefresh({
|
return shouldPauseDashboardRefresh({
|
||||||
|
|
@ -207,42 +195,19 @@ 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;
|
||||||
const restored = restoreDashboardTileSnapshots(
|
setDashboard(nextDashboard);
|
||||||
nextDashboard,
|
|
||||||
nextDashboard.state === "ready"
|
|
||||||
? tileSnapshotStore.restore({
|
|
||||||
currentRevisionId: nextDashboard.currentRevisionId,
|
|
||||||
schemaVersion: nextDashboard.schemaVersion,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
setDashboard(restored.dashboard);
|
|
||||||
|
|
||||||
const currentRun = ++hydrationRun;
|
const currentRun = ++hydrationRun;
|
||||||
if (
|
if (
|
||||||
restored.dashboard.state === "ready" &&
|
nextDashboard.state === "ready" &&
|
||||||
restored.dashboard.liveDatasourceHydration?.enabled !== false
|
nextDashboard.liveDatasourceHydration?.enabled !== false
|
||||||
) {
|
) {
|
||||||
const tiles = dashboardHydrationTiles(restored.dashboard.document).filter(
|
const tiles = dashboardHydrationTiles(nextDashboard.document).filter(
|
||||||
(tile) => tileBackoff.canAttempt(dashboardTileKey(tile)),
|
(tile) => tileBackoff.canAttempt(dashboardTileKey(tile)),
|
||||||
);
|
);
|
||||||
const tileSignal = requestAborter.beginTileRun();
|
const tileSignal = requestAborter.beginTileRun();
|
||||||
setHydratingItemIds(
|
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
||||||
new Set(
|
hydrateDashboardTiles(tiles, currentRun, tileSignal);
|
||||||
tiles
|
|
||||||
.map(dashboardTileKey)
|
|
||||||
.filter((key) => !restored.restoredItemIds.has(key)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
hydrateDashboardTiles(
|
|
||||||
tiles,
|
|
||||||
currentRun,
|
|
||||||
tileSignal,
|
|
||||||
{
|
|
||||||
currentRevisionId: restored.dashboard.currentRevisionId,
|
|
||||||
schemaVersion: restored.dashboard.schemaVersion,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setHydratingItemIds(new Set());
|
setHydratingItemIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
@ -271,18 +236,12 @@ export default function App() {
|
||||||
tiles: DashboardTileReference[],
|
tiles: DashboardTileReference[],
|
||||||
run: number,
|
run: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
|
||||||
) {
|
) {
|
||||||
void runDashboardHydrationQueue({
|
void runDashboardHydrationQueue({
|
||||||
concurrency: dashboardTileHydrationConcurrency,
|
concurrency: dashboardTileHydrationConcurrency,
|
||||||
hydrate: async (tile) => {
|
hydrate: async (tile) => {
|
||||||
const key = dashboardTileKey(tile);
|
const key = dashboardTileKey(tile);
|
||||||
const result = await hydrateDashboardTile(
|
const result = await hydrateDashboardTile(tile, run, signal);
|
||||||
tile,
|
|
||||||
run,
|
|
||||||
signal,
|
|
||||||
snapshotContext,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "ready") {
|
if (result === "ready") {
|
||||||
tileBackoff.recordSuccess(key);
|
tileBackoff.recordSuccess(key);
|
||||||
|
|
@ -299,7 +258,6 @@ export default function App() {
|
||||||
tile: DashboardTileReference,
|
tile: DashboardTileReference,
|
||||||
run: number,
|
run: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
|
||||||
): Promise<DashboardTileHydrationResult> {
|
): Promise<DashboardTileHydrationResult> {
|
||||||
const key = dashboardTileKey(tile);
|
const key = dashboardTileKey(tile);
|
||||||
|
|
||||||
|
|
@ -328,10 +286,6 @@ export default function App() {
|
||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
tileSnapshotStore.saveReadyTile({
|
|
||||||
...snapshotContext,
|
|
||||||
response: readyTileResponse,
|
|
||||||
});
|
|
||||||
return "ready";
|
return "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAbortError(error) && !cancelled) {
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
|
@ -398,14 +352,6 @@ function getThemeStorage(): Storage | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTileSnapshotStorage(): Storage | undefined {
|
|
||||||
try {
|
|
||||||
return window.localStorage;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbortError(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
|
|
@ -415,41 +361,6 @@ function isAbortError(error: unknown): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.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>,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
createDashboardTileSnapshotStore,
|
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
runDashboardHydrationQueue,
|
runDashboardHydrationQueue,
|
||||||
|
|
@ -132,139 +131,6 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
backoff.recordSuccess("telemetry:infra-ram");
|
backoff.recordSuccess("telemetry:infra-ram");
|
||||||
expect(backoff.canAttempt("telemetry:infra-ram", 107_000)).toBe(true);
|
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function waitFor(predicate: () => boolean) {
|
async function waitFor(predicate: () => boolean) {
|
||||||
|
|
@ -275,27 +141,3 @@ async function waitFor(predicate: () => boolean) {
|
||||||
|
|
||||||
throw new Error("condition was not met");
|
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,39 +3,6 @@ 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 {
|
||||||
|
|
@ -178,314 +145,3 @@ export function createDashboardTileBackoff() {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
|
||||||
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 {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue