feat(web): hydrate dashboard tiles independently #42
5 changed files with 124 additions and 22 deletions
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,15 +359,17 @@ 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]
|
||||||
: ["api", "dashboard", "tile", tile.kind, tile.id];
|
: tile.kind === "service"
|
||||||
|
? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id]
|
||||||
|
: ["api", "dashboard", "tile", tile.kind, tile.id];
|
||||||
return `/${parts.map(encodeURIComponent).join("/")}`;
|
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,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue