perf(web): cache dashboard tile responses
This commit is contained in:
parent
cda40401ca
commit
bc1f64d566
2 changed files with 386 additions and 4 deletions
|
|
@ -1,6 +1,12 @@
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { 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,
|
||||||
|
handleDashboardTileRoute,
|
||||||
|
loadDashboardResponse,
|
||||||
|
loadDashboardTileResponse,
|
||||||
|
} from "./dashboard";
|
||||||
|
|
||||||
describe("dashboard API route", () => {
|
describe("dashboard API route", () => {
|
||||||
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
||||||
|
|
@ -145,6 +151,243 @@ 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 () =>
|
||||||
|
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("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();
|
||||||
|
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,
|
||||||
|
now: () => 1_000,
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
tileCache: cache,
|
||||||
|
});
|
||||||
|
const second = loadDashboardTileResponse(tile, {
|
||||||
|
fetch,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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 +422,43 @@ function jsonResponse(payload: unknown): Response {
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,63 @@ export interface LoadDashboardResponseOptions
|
||||||
DatasourceResolutionOptions {
|
DatasourceResolutionOptions {
|
||||||
disableLiveDatasources?: boolean;
|
disableLiveDatasources?: boolean;
|
||||||
hydrateLiveDatasources?: boolean;
|
hydrateLiveDatasources?: boolean;
|
||||||
|
now?: () => number;
|
||||||
|
tileCache?: DashboardTileCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardTileCacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
response: DashboardTileResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTileCache {
|
||||||
|
resolve(
|
||||||
|
key: string,
|
||||||
|
ttlMs: number,
|
||||||
|
now: number,
|
||||||
|
load: () => Promise<DashboardTileResolution>,
|
||||||
|
): Promise<DashboardTileResolution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardTileResponseHeaders = {
|
||||||
|
"Cache-Control": "private, max-age=5, stale-while-revalidate=30",
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultDashboardTileCache = createDashboardTileCache();
|
||||||
|
|
||||||
|
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 cached.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = inFlight.get(key);
|
||||||
|
if (active) return active;
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDashboardResponse(
|
export async function loadDashboardResponse(
|
||||||
|
|
@ -88,14 +146,25 @@ export async function loadDashboardTileResponse(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveDashboardTile(dashboard.document, tile, options);
|
const cache = options.tileCache || defaultDashboardTileCache;
|
||||||
|
const now = options.now?.() ?? Date.now();
|
||||||
|
|
||||||
|
return cache.resolve(
|
||||||
|
dashboardTileCacheKey(tile),
|
||||||
|
dashboardTileTtlMs(dashboard.document, tile),
|
||||||
|
now,
|
||||||
|
() => resolveDashboardTile(dashboard.document, tile, 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 +173,42 @@ 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 function dashboardTileCacheKey(tile: DashboardTileReference): string {
|
||||||
|
return JSON.stringify(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue