fix(web): scope dashboard tile hydration

This commit is contained in:
vince 2026-06-20 12:34:52 +02:00
parent 2963c5dfde
commit 2a1d94195e
5 changed files with 124 additions and 22 deletions

View file

@ -1,7 +1,8 @@
import { renderToString } from "react-dom/server"; import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import { AppStateView } from "./App"; import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import { AppStateView, dashboardHydrationTiles } from "./App";
describe("React app dashboard state view", () => { describe("React app dashboard state view", () => {
test("renders loading dashboard state", () => { test("renders loading dashboard state", () => {
@ -54,7 +55,7 @@ describe("React app dashboard state view", () => {
}} }}
hydratingItemIds={new Set([ hydratingItemIds={new Set([
"telemetry:service-uptime", "telemetry:service-uptime",
"service:identity", "service:core-services:identity",
"module:ambient", "module:ambient",
"status:runtime:status", "status:runtime:status",
])} ])}
@ -73,4 +74,12 @@ describe("React app dashboard state view", () => {
expect(html).toContain('data-severity="loading" data-model-id="ambient"'); expect(html).toContain('data-severity="loading" data-model-id="ambient"');
expect(html).toContain('data-severity="loading" data-model-id="runtime:status"'); expect(html).toContain('data-severity="loading" data-model-id="runtime:status"');
}); });
test("hydrates every status cell from the Dimension Lab shell", () => {
expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({
kind: "status",
stripId: "footer-status",
id: "auto-refresh",
});
});
}); });

View file

@ -21,7 +21,7 @@ import {
type DashboardTileReference = type DashboardTileReference =
| { kind: "telemetry"; id: string } | { kind: "telemetry"; id: string }
| { kind: "service"; id: string } | { kind: "service"; groupId: string; id: string }
| { kind: "module"; id: string } | { kind: "module"; id: string }
| { kind: "status"; stripId: string; id: string }; | { kind: "status"; stripId: string; id: string };
@ -295,7 +295,7 @@ function markHydratingItems(
serviceGroups: dashboard.serviceGroups.map((group) => ({ serviceGroups: dashboard.serviceGroups.map((group) => ({
...group, ...group,
services: group.services.map((service) => services: group.services.map((service) =>
hydratingItemIds.has(`service:${service.id}`) hydratingItemIds.has(`service:${group.id}:${service.id}`)
? { ? {
...service, ...service,
severity: "loading", severity: "loading",
@ -325,14 +325,20 @@ function markHydratingItems(
}; };
} }
function dashboardHydrationTiles(document: DashboardDocument): DashboardTileReference[] { export function dashboardHydrationTiles(
document: DashboardDocument,
): DashboardTileReference[] {
const telemetry = document.telemetry const telemetry = document.telemetry
.filter((card) => card.datasource?.type === "external") .filter((card) => card.datasource?.type === "external")
.map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id })); .map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id }));
const services = document.serviceGroups.flatMap((group) => const services = document.serviceGroups.flatMap((group) =>
group.services group.services
.filter((service) => service.datasource?.type === "external") .filter((service) => service.datasource?.type === "external")
.map((service): DashboardTileReference => ({ kind: "service", id: service.id })), .map((service): DashboardTileReference => ({
kind: "service",
groupId: group.id,
id: service.id,
})),
); );
const modules = (document.modules || []) const modules = (document.modules || [])
.filter((module) => .filter((module) =>
@ -342,7 +348,6 @@ function dashboardHydrationTiles(document: DashboardDocument): DashboardTileRefe
.map((module): DashboardTileReference => ({ kind: "module", id: module.id })); .map((module): DashboardTileReference => ({ kind: "module", id: module.id }));
const status = document.statusStrips.flatMap((strip) => const status = document.statusStrips.flatMap((strip) =>
strip.items strip.items
.filter((item) => item.id !== "auto-refresh")
.map((item): DashboardTileReference => ({ .map((item): DashboardTileReference => ({
kind: "status", kind: "status",
stripId: strip.id, stripId: strip.id,
@ -354,14 +359,16 @@ function dashboardHydrationTiles(document: DashboardDocument): DashboardTileRefe
} }
function dashboardTileKey(tile: DashboardTileReference): string { function dashboardTileKey(tile: DashboardTileReference): string {
return tile.kind === "status" if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
? `${tile.kind}:${tile.stripId}:${tile.id}` if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
: `${tile.kind}:${tile.id}`; return `${tile.kind}:${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]
: tile.kind === "service"
? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id]
: ["api", "dashboard", "tile", tile.kind, tile.id]; : ["api", "dashboard", "tile", tile.kind, tile.id];
return `/${parts.map(encodeURIComponent).join("/")}`; return `/${parts.map(encodeURIComponent).join("/")}`;
} }
@ -380,13 +387,16 @@ function applyDashboardTile(
} }
if (response.tile.kind === "service") { if (response.tile.kind === "service") {
const tile = response.tile;
return { return {
...document, ...document,
serviceGroups: document.serviceGroups.map((group) => ({ serviceGroups: document.serviceGroups.map((group) => ({
...group, ...group,
services: group.services.map((service) => services: group.id === tile.groupId
service.id === response.tile.id ? response.item as ServiceEntry : service, ? group.services.map((service) =>
), service.id === tile.id ? response.item as ServiceEntry : service,
)
: group.services,
})), })),
}; };
} }

View file

@ -12,7 +12,7 @@ import type {
export type DashboardTileReference = export type DashboardTileReference =
| { kind: "telemetry"; id: string } | { kind: "telemetry"; id: string }
| { kind: "service"; id: string } | { kind: "service"; groupId: string; id: string }
| { kind: "module"; id: string } | { kind: "module"; id: string }
| { kind: "status"; stripId: string; id: string }; | { kind: "status"; stripId: string; id: string };
@ -32,6 +32,11 @@ export type DashboardTileResolution =
state: "not_found"; state: "not_found";
tile: DashboardTileReference; tile: DashboardTileReference;
message: string; message: string;
}
| {
state: "disabled";
tile: DashboardTileReference;
message: string;
}; };
export interface DatasourceResolutionOptions { export interface DatasourceResolutionOptions {
@ -90,8 +95,8 @@ export async function resolveDashboardTile(
if (tile.kind === "service") { if (tile.kind === "service") {
const service = document.serviceGroups const service = document.serviceGroups
.flatMap((group) => group.services) .find((group) => group.id === tile.groupId)
.find((item) => item.id === tile.id); ?.services.find((item) => item.id === tile.id);
if (!service) return missingTile(tile); if (!service) return missingTile(tile);
return { return {
@ -832,7 +837,7 @@ function missingTile(tile: DashboardTileReference): DashboardTileResolution {
} }
function tileKey(tile: DashboardTileReference): string { function tileKey(tile: DashboardTileReference): string {
return tile.kind === "status" if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
? `${tile.kind}:${tile.stripId}:${tile.id}` if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
: `${tile.kind}:${tile.id}`; return `${tile.kind}:${tile.id}`;
} }

View file

@ -113,6 +113,65 @@ describe("dashboard API route", () => {
expect.objectContaining({ cache: "no-store" }), expect.objectContaining({ cache: "no-store" }),
); );
}); });
test("hydrates a service tile with its service group identity", async () => {
const fetch = vi.fn(async () =>
jsonResponse({
status: "UP",
ping: 42,
}),
);
const response = await loadDashboardTileResponse(
{ kind: "service", groupId: "essentials", id: "vaultwarden" },
{
fetch,
refreshSeedDocument: true,
seedIfEmpty: true,
},
);
expect(response.state).toBe("ready");
if (response.state !== "ready") throw new Error("expected ready tile");
expect(response.tile).toEqual({
kind: "service",
groupId: "essentials",
id: "vaultwarden",
});
expect(response.item).toMatchObject({
id: "vaultwarden",
severity: "ok",
detail: "42 ms",
});
});
test("does not hydrate tile routes when live datasources are disabled", async () => {
const previous = process.env.DISABLE_LIVE_DATASOURCES;
process.env.DISABLE_LIVE_DATASOURCES = "1";
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("live datasource fetch should not run when disabled"),
);
try {
const response = await loadDashboardTileResponse(
{ kind: "telemetry", id: "infra-ram" },
{
refreshSeedDocument: true,
seedIfEmpty: true,
},
);
expect(response.state).toBe("disabled");
expect(fetch).not.toHaveBeenCalled();
} finally {
fetch.mockRestore();
if (previous === undefined) {
delete process.env.DISABLE_LIVE_DATASOURCES;
} else {
process.env.DISABLE_LIVE_DATASOURCES = previous;
}
}
});
}); });
function jsonResponse(payload: unknown): Response { function jsonResponse(payload: unknown): Response {

View file

@ -63,6 +63,17 @@ export async function loadDashboardTileResponse(
tile: DashboardTileReference, tile: DashboardTileReference,
options: LoadDashboardResponseOptions = {}, options: LoadDashboardResponseOptions = {},
): Promise<DashboardTileResolution> { ): Promise<DashboardTileResolution> {
if (
options.disableLiveDatasources ||
process.env.DISABLE_LIVE_DATASOURCES === "1"
) {
return {
state: "disabled",
tile,
message: "Live datasource hydration is disabled.",
};
}
const dashboard = loadDashboardRuntime(undefined, { const dashboard = loadDashboardRuntime(undefined, {
refreshSeedDocument: options.refreshSeedDocument ?? true, refreshSeedDocument: options.refreshSeedDocument ?? true,
seedIfEmpty: options.seedIfEmpty ?? true, seedIfEmpty: options.seedIfEmpty ?? true,
@ -95,7 +106,7 @@ export async function handleDashboardTileRoute(pathname: string): Promise<Respon
const response = await loadDashboardTileResponse(tile); const response = await loadDashboardTileResponse(tile);
return Response.json(response, { return Response.json(response, {
status: response.state === "ready" ? 200 : 404, status: response.state === "not_found" ? 404 : 200,
}); });
} }
@ -107,10 +118,18 @@ function parseDashboardTilePath(pathname: string): DashboardTileReference | null
} }
const id = decodeURIComponent(firstId); const id = decodeURIComponent(firstId);
if (kind === "telemetry" || kind === "service" || kind === "module") { if (kind === "telemetry" || kind === "module") {
return { kind, id }; return { kind, id };
} }
if (kind === "service" && secondId) {
return {
kind,
groupId: id,
id: decodeURIComponent(secondId),
};
}
if (kind === "status" && secondId) { if (kind === "status" && secondId) {
return { return {
kind, kind,