perf(web): restore last known dashboard tiles
This commit is contained in:
parent
243d71686d
commit
4ce42fb52b
4 changed files with 412 additions and 8 deletions
|
|
@ -2,7 +2,11 @@ import { renderToString } from "react-dom/server";
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||
import { AppStateView, dashboardHydrationTiles } from "./App";
|
||||
import {
|
||||
AppStateView,
|
||||
dashboardHydrationTiles,
|
||||
restoreDashboardTileSnapshots,
|
||||
} from "./App";
|
||||
|
||||
describe("React app dashboard state view", () => {
|
||||
test("renders loading dashboard state", () => {
|
||||
|
|
@ -120,4 +124,39 @@ 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,10 +9,13 @@ import type {
|
|||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||
import {
|
||||
attachDashboardRefreshLifecycle,
|
||||
createDashboardTileSnapshotStore,
|
||||
createDashboardTileBackoff,
|
||||
createDashboardRequestAborter,
|
||||
runDashboardHydrationQueue,
|
||||
shouldPauseDashboardRefresh,
|
||||
type DashboardTileSnapshotStoreContext,
|
||||
type RestoredDashboardTileSnapshot,
|
||||
} from "$lib/client/dashboard-refresh";
|
||||
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||
import {
|
||||
|
|
@ -47,6 +50,7 @@ type DashboardTileResponse =
|
|||
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
||||
|
||||
const dashboardTileHydrationConcurrency = 6;
|
||||
type DashboardTileSnapshotStore = ReturnType<typeof createDashboardTileSnapshotStore>;
|
||||
|
||||
export function AppStateView({
|
||||
dashboard,
|
||||
|
|
@ -164,6 +168,9 @@ export default function App() {
|
|||
let hydrationRun = 0;
|
||||
const requestAborter = createDashboardRequestAborter();
|
||||
const tileBackoff = createDashboardTileBackoff();
|
||||
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
||||
getTileSnapshotStorage(),
|
||||
);
|
||||
|
||||
function refreshPaused() {
|
||||
return shouldPauseDashboardRefresh({
|
||||
|
|
@ -195,19 +202,42 @@ export default function App() {
|
|||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||
|
||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
||||
setDashboard(nextDashboard);
|
||||
const restored = restoreDashboardTileSnapshots(
|
||||
nextDashboard,
|
||||
nextDashboard.state === "ready"
|
||||
? tileSnapshotStore.restore({
|
||||
currentRevisionId: nextDashboard.currentRevisionId,
|
||||
schemaVersion: nextDashboard.schemaVersion,
|
||||
})
|
||||
: [],
|
||||
);
|
||||
setDashboard(restored.dashboard);
|
||||
|
||||
const currentRun = ++hydrationRun;
|
||||
if (
|
||||
nextDashboard.state === "ready" &&
|
||||
nextDashboard.liveDatasourceHydration?.enabled !== false
|
||||
restored.dashboard.state === "ready" &&
|
||||
restored.dashboard.liveDatasourceHydration?.enabled !== false
|
||||
) {
|
||||
const tiles = dashboardHydrationTiles(nextDashboard.document).filter(
|
||||
const tiles = dashboardHydrationTiles(restored.dashboard.document).filter(
|
||||
(tile) => tileBackoff.canAttempt(dashboardTileKey(tile)),
|
||||
);
|
||||
const tileSignal = requestAborter.beginTileRun();
|
||||
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
||||
hydrateDashboardTiles(tiles, currentRun, tileSignal);
|
||||
setHydratingItemIds(
|
||||
new Set(
|
||||
tiles
|
||||
.map(dashboardTileKey)
|
||||
.filter((key) => !restored.restoredItemIds.has(key)),
|
||||
),
|
||||
);
|
||||
hydrateDashboardTiles(
|
||||
tiles,
|
||||
currentRun,
|
||||
tileSignal,
|
||||
{
|
||||
currentRevisionId: restored.dashboard.currentRevisionId,
|
||||
schemaVersion: restored.dashboard.schemaVersion,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setHydratingItemIds(new Set());
|
||||
}
|
||||
|
|
@ -236,12 +266,18 @@ export default function App() {
|
|||
tiles: DashboardTileReference[],
|
||||
run: number,
|
||||
signal: AbortSignal,
|
||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||
) {
|
||||
void runDashboardHydrationQueue({
|
||||
concurrency: dashboardTileHydrationConcurrency,
|
||||
hydrate: async (tile) => {
|
||||
const key = dashboardTileKey(tile);
|
||||
const result = await hydrateDashboardTile(tile, run, signal);
|
||||
const result = await hydrateDashboardTile(
|
||||
tile,
|
||||
run,
|
||||
signal,
|
||||
snapshotContext,
|
||||
);
|
||||
|
||||
if (result === "ready") {
|
||||
tileBackoff.recordSuccess(key);
|
||||
|
|
@ -258,6 +294,7 @@ export default function App() {
|
|||
tile: DashboardTileReference,
|
||||
run: number,
|
||||
signal: AbortSignal,
|
||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||
): Promise<DashboardTileHydrationResult> {
|
||||
const key = dashboardTileKey(tile);
|
||||
|
||||
|
|
@ -286,6 +323,10 @@ export default function App() {
|
|||
}
|
||||
: current,
|
||||
);
|
||||
tileSnapshotStore.saveReadyTile({
|
||||
...snapshotContext,
|
||||
response: readyTileResponse,
|
||||
});
|
||||
return "ready";
|
||||
} catch (error) {
|
||||
if (!isAbortError(error) && !cancelled) {
|
||||
|
|
@ -352,6 +393,14 @@ function getThemeStorage(): Storage | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function getTileSnapshotStorage(): Storage | undefined {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
|
|
@ -361,6 +410,41 @@ 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(
|
||||
dashboard: UiDashboardPreview,
|
||||
hydratingItemIds?: ReadonlySet<string>,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
attachDashboardRefreshLifecycle,
|
||||
createDashboardTileSnapshotStore,
|
||||
createDashboardTileBackoff,
|
||||
createDashboardRequestAborter,
|
||||
runDashboardHydrationQueue,
|
||||
|
|
@ -131,6 +132,67 @@ describe("dashboard refresh lifecycle", () => {
|
|||
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",
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function waitFor(predicate: () => boolean) {
|
||||
|
|
@ -141,3 +203,27 @@ async function waitFor(predicate: () => boolean) {
|
|||
|
||||
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,6 +3,39 @@ export interface DashboardRefreshPauseState {
|
|||
online: boolean;
|
||||
}
|
||||
|
||||
export type DashboardTileReference =
|
||||
| { kind: "telemetry"; id: string }
|
||||
| { kind: "service"; groupId: string; id: string }
|
||||
| { kind: "module"; id: string }
|
||||
| { kind: "status"; stripId: string; id: string };
|
||||
|
||||
type SnapshotMetricValue = Record<string, unknown>;
|
||||
|
||||
export type DashboardTileSnapshotItem = {
|
||||
detail?: string;
|
||||
id: string;
|
||||
label?: string;
|
||||
severity?: string;
|
||||
value?: SnapshotMetricValue | string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export type DashboardTileSnapshotResponse =
|
||||
| {
|
||||
state: "ready";
|
||||
tile: DashboardTileReference;
|
||||
item: DashboardTileSnapshotItem;
|
||||
}
|
||||
| {
|
||||
state: "not_found";
|
||||
tile: DashboardTileReference;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
state: "disabled";
|
||||
tile: DashboardTileReference;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function shouldPauseDashboardRefresh(
|
||||
state: DashboardRefreshPauseState,
|
||||
): boolean {
|
||||
|
|
@ -145,3 +178,165 @@ 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 payload as DashboardTileSnapshotPayload;
|
||||
} 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 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