perf(web): batch dashboard tile hydration
This commit is contained in:
parent
63b008095f
commit
de87464ef1
7 changed files with 518 additions and 40 deletions
|
|
@ -52,6 +52,11 @@ type DashboardTileResponse =
|
|||
message: string;
|
||||
};
|
||||
|
||||
type DashboardTilesBatchResponse = {
|
||||
state: "ready";
|
||||
tiles: DashboardTileResponse[];
|
||||
};
|
||||
|
||||
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
||||
|
||||
const dashboardTileHydrationConcurrency = 6;
|
||||
|
|
@ -292,6 +297,7 @@ export default function App() {
|
|||
const modelIds = tiles.map(dashboardTileModelId);
|
||||
|
||||
void runViewportAwareDashboardHydrationQueue({
|
||||
batchSize: dashboardTileHydrationConcurrency,
|
||||
collectVisibleModelIds: async () => {
|
||||
await waitForDashboardRenderFrame(signal);
|
||||
return collectVisibleDashboardModelIds({
|
||||
|
|
@ -321,6 +327,23 @@ export default function App() {
|
|||
tileBackoff.recordFailure(key);
|
||||
}
|
||||
},
|
||||
hydrateBatch: async (batch) => {
|
||||
const results = await hydrateDashboardTileBatch(
|
||||
batch,
|
||||
run,
|
||||
signal,
|
||||
snapshotContext,
|
||||
);
|
||||
|
||||
for (const { result, tile } of results) {
|
||||
const key = dashboardTileKey(tile);
|
||||
if (result === "ready") {
|
||||
tileBackoff.recordSuccess(key);
|
||||
} else if (result === "failed") {
|
||||
tileBackoff.recordFailure(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
items: tiles,
|
||||
onAllItemsSettled: () => performanceMarks.markAllTilesSettled(),
|
||||
onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(),
|
||||
|
|
@ -335,6 +358,69 @@ export default function App() {
|
|||
});
|
||||
}
|
||||
|
||||
async function hydrateDashboardTileBatch(
|
||||
tiles: DashboardTileReference[],
|
||||
run: number,
|
||||
signal: AbortSignal,
|
||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||
): Promise<Array<{ result: DashboardTileHydrationResult; tile: DashboardTileReference }>> {
|
||||
try {
|
||||
const response = await fetch("/api/dashboard/tiles", {
|
||||
body: JSON.stringify({ tiles }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
signal,
|
||||
});
|
||||
const batchResponse = (await response.json()) as DashboardTilesBatchResponse;
|
||||
|
||||
if (
|
||||
cancelled ||
|
||||
signal.aborted ||
|
||||
run !== hydrationRun ||
|
||||
!response.ok ||
|
||||
batchResponse.state !== "ready"
|
||||
) {
|
||||
throw new Error("Dashboard tile batch hydration failed");
|
||||
}
|
||||
|
||||
const responsesByKey = new Map(
|
||||
batchResponse.tiles.map((tileResponse) => [
|
||||
dashboardTileKey(tileResponse.tile),
|
||||
tileResponse,
|
||||
]),
|
||||
);
|
||||
|
||||
return tiles.map((tile) => {
|
||||
const key = dashboardTileKey(tile);
|
||||
try {
|
||||
return {
|
||||
tile,
|
||||
result: applyDashboardTileHydrationResponse(
|
||||
tile,
|
||||
responsesByKey.get(key),
|
||||
run,
|
||||
signal,
|
||||
snapshotContext,
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
finishDashboardTileHydration(key, run, signal);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted || cancelled || run !== hydrationRun) {
|
||||
return tiles.map((tile) => ({ result: "aborted", tile }));
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
tiles.map(async (tile) => ({
|
||||
tile,
|
||||
result: await hydrateDashboardTile(tile, run, signal, snapshotContext),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateDashboardTile(
|
||||
tile: DashboardTileReference,
|
||||
run: number,
|
||||
|
|
@ -347,32 +433,23 @@ export default function App() {
|
|||
const response = await fetch(dashboardTileUrl(tile), { signal });
|
||||
const tileResponse = (await response.json()) as DashboardTileResponse;
|
||||
|
||||
if (
|
||||
cancelled ||
|
||||
signal.aborted ||
|
||||
run !== hydrationRun ||
|
||||
!response.ok ||
|
||||
tileResponse.state !== "ready"
|
||||
) {
|
||||
if (!response.ok) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const result = applyDashboardTileHydrationResponse(
|
||||
tile,
|
||||
tileResponse,
|
||||
run,
|
||||
signal,
|
||||
snapshotContext,
|
||||
);
|
||||
if (result !== "ready") {
|
||||
return signal.aborted || cancelled || run !== hydrationRun
|
||||
? "aborted"
|
||||
: "failed";
|
||||
}
|
||||
|
||||
const readyTileResponse = tileResponse;
|
||||
setDashboard((current) =>
|
||||
current?.state === "ready"
|
||||
? {
|
||||
...current,
|
||||
document: applyDashboardTile(current.document, readyTileResponse),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
tileSnapshotStore.saveReadyTile({
|
||||
...snapshotContext,
|
||||
response: readyTileResponse,
|
||||
});
|
||||
performanceMarks.markFirstTileReady();
|
||||
return "ready";
|
||||
} catch (error) {
|
||||
if (!isAbortError(error) && !cancelled) {
|
||||
|
|
@ -381,13 +458,58 @@ export default function App() {
|
|||
}
|
||||
return "aborted";
|
||||
} finally {
|
||||
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
||||
setHydratingItemIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
finishDashboardTileHydration(key, run, signal);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDashboardTileHydrationResponse(
|
||||
tile: DashboardTileReference,
|
||||
tileResponse: DashboardTileResponse | undefined,
|
||||
run: number,
|
||||
signal: AbortSignal,
|
||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||
): DashboardTileHydrationResult {
|
||||
if (
|
||||
cancelled ||
|
||||
signal.aborted ||
|
||||
run !== hydrationRun ||
|
||||
!tileResponse ||
|
||||
dashboardTileKey(tileResponse.tile) !== dashboardTileKey(tile) ||
|
||||
tileResponse.state !== "ready"
|
||||
) {
|
||||
return signal.aborted || cancelled || run !== hydrationRun
|
||||
? "aborted"
|
||||
: "failed";
|
||||
}
|
||||
|
||||
const readyTileResponse = tileResponse;
|
||||
setDashboard((current) =>
|
||||
current?.state === "ready"
|
||||
? {
|
||||
...current,
|
||||
document: applyDashboardTile(current.document, readyTileResponse),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
tileSnapshotStore.saveReadyTile({
|
||||
...snapshotContext,
|
||||
response: readyTileResponse,
|
||||
});
|
||||
performanceMarks.markFirstTileReady();
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function finishDashboardTileHydration(
|
||||
key: string,
|
||||
run: number,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
||||
setHydratingItemIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
createDashboardTileBackoff,
|
||||
createDashboardRequestAborter,
|
||||
runDashboardHydrationQueue,
|
||||
runDashboardHydrationBatchQueue,
|
||||
runViewportAwareDashboardHydrationQueue,
|
||||
shouldPauseDashboardRefresh,
|
||||
splitDashboardHydrationItemsByVisibility,
|
||||
|
|
@ -121,6 +122,25 @@ describe("dashboard refresh lifecycle", () => {
|
|||
expect(maxActive).toBe(2);
|
||||
});
|
||||
|
||||
test("hydrates queued work in fixed-size batches", async () => {
|
||||
const batches: number[][] = [];
|
||||
|
||||
await runDashboardHydrationBatchQueue({
|
||||
batchSize: 3,
|
||||
hydrateBatch: (items) => {
|
||||
batches.push(items);
|
||||
},
|
||||
items: [1, 2, 3, 4, 5, 6, 7],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(batches).toEqual([
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7],
|
||||
]);
|
||||
});
|
||||
|
||||
test("hydrates visible items before deferred items and waits for idle", async () => {
|
||||
const calls: string[] = [];
|
||||
const idleReleases: Array<() => void> = [];
|
||||
|
|
|
|||
|
|
@ -152,10 +152,12 @@ export async function runDashboardHydrationQueue<TItem>(
|
|||
}
|
||||
|
||||
export interface DashboardViewportHydrationQueueOptions<TItem> {
|
||||
batchSize?: number;
|
||||
collectVisibleModelIds: () => Promise<ReadonlySet<string>>;
|
||||
concurrency: number;
|
||||
getModelId: (item: TItem) => string;
|
||||
hydrate: (item: TItem) => Promise<void> | void;
|
||||
hydrateBatch?: (items: TItem[]) => Promise<void> | void;
|
||||
items: TItem[];
|
||||
onAllItemsSettled?: () => void;
|
||||
onVisibleItemsSettled?: () => void;
|
||||
|
|
@ -175,12 +177,7 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
|||
visibleModelIds,
|
||||
});
|
||||
|
||||
await runDashboardHydrationQueue({
|
||||
concurrency: options.concurrency,
|
||||
hydrate: options.hydrate,
|
||||
items: visible,
|
||||
signal: options.signal,
|
||||
});
|
||||
await runDashboardHydrationItems(options, visible);
|
||||
if (options.signal.aborted) return;
|
||||
options.onVisibleItemsSettled?.();
|
||||
|
||||
|
|
@ -192,13 +189,44 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
|||
await options.waitForIdle();
|
||||
if (options.signal.aborted) return;
|
||||
|
||||
await runDashboardHydrationQueue({
|
||||
concurrency: options.concurrency,
|
||||
hydrate: options.hydrate,
|
||||
items: deferred,
|
||||
await runDashboardHydrationItems(options, deferred);
|
||||
options.onAllItemsSettled?.();
|
||||
}
|
||||
|
||||
async function runDashboardHydrationItems<TItem>(
|
||||
options: DashboardViewportHydrationQueueOptions<TItem>,
|
||||
items: TItem[],
|
||||
): Promise<void> {
|
||||
if (!options.hydrateBatch) {
|
||||
await runDashboardHydrationQueue({
|
||||
concurrency: options.concurrency,
|
||||
hydrate: options.hydrate,
|
||||
items,
|
||||
signal: options.signal,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runDashboardHydrationBatchQueue({
|
||||
batchSize: options.batchSize ?? options.concurrency,
|
||||
hydrateBatch: options.hydrateBatch,
|
||||
items,
|
||||
signal: options.signal,
|
||||
});
|
||||
options.onAllItemsSettled?.();
|
||||
}
|
||||
|
||||
export async function runDashboardHydrationBatchQueue<TItem>(options: {
|
||||
batchSize: number;
|
||||
hydrateBatch: (items: TItem[]) => Promise<void> | void;
|
||||
items: TItem[];
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const batchSize = Math.max(1, Math.floor(options.batchSize));
|
||||
|
||||
for (let index = 0; index < options.items.length; index += batchSize) {
|
||||
if (options.signal.aborted) return;
|
||||
await options.hydrateBatch(options.items.slice(index, index + batchSize));
|
||||
}
|
||||
}
|
||||
|
||||
export function splitDashboardHydrationItemsByVisibility<TItem>(options: {
|
||||
|
|
|
|||
53
apps/web/src/server/index.test.ts
Normal file
53
apps/web/src/server/index.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { afterAll, describe, expect, test, vi } from "vitest";
|
||||
import { handleRequest } from "./index";
|
||||
|
||||
describe("server request routing", () => {
|
||||
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
afterAll(() => {
|
||||
consoleInfo.mockRestore();
|
||||
});
|
||||
|
||||
test("routes dashboard tile batch requests", async () => {
|
||||
const response = await handleRequest(
|
||||
new Request("https://example.test/api/dashboard/tiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
tiles: [
|
||||
{
|
||||
kind: "status",
|
||||
stripId: "footer-status",
|
||||
id: "auto-refresh",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
state: "ready",
|
||||
tiles: [
|
||||
{
|
||||
state: "ready",
|
||||
tile: {
|
||||
kind: "status",
|
||||
stripId: "footer-status",
|
||||
id: "auto-refresh",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects non-post dashboard tile batch requests", async () => {
|
||||
const response = await handleRequest(
|
||||
new Request("https://example.test/api/dashboard/tiles", {
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { extname, normalize } from "node:path";
|
||||
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
||||
import { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard";
|
||||
import {
|
||||
handleDashboardRoute,
|
||||
handleDashboardTileRoute,
|
||||
handleDashboardTilesRoute,
|
||||
} from "./routes/dashboard";
|
||||
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
|
|
@ -23,6 +27,11 @@ export async function handleRequest(request: Request): Promise<Response> {
|
|||
return handleDashboardRoute();
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/dashboard/tiles") {
|
||||
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
||||
return handleDashboardTilesRoute(request);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/dashboard/tile/")) {
|
||||
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
||||
return handleDashboardTileRoute(url.pathname);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
|||
import {
|
||||
createDashboardTileCache,
|
||||
dashboardTileCacheKey,
|
||||
handleDashboardTilesRoute,
|
||||
handleDashboardTileRoute,
|
||||
loadDashboardResponse,
|
||||
loadDashboardTilesResponse,
|
||||
loadDashboardTileResponse,
|
||||
type DashboardTileResolutionLogEvent,
|
||||
} from "./dashboard";
|
||||
|
|
@ -563,6 +565,115 @@ describe("dashboard API route", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("serves batch tile route responses", async () => {
|
||||
const response = await handleDashboardTilesRoute(
|
||||
new Request("https://example.test/api/dashboard/tiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
tiles: [
|
||||
{
|
||||
kind: "status",
|
||||
stripId: "footer-status",
|
||||
id: "auto-refresh",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
tileCache: createDashboardTileCache(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe(
|
||||
"private, max-age=5, stale-while-revalidate=30",
|
||||
);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
state: "ready",
|
||||
tiles: [
|
||||
{
|
||||
state: "ready",
|
||||
tile: {
|
||||
kind: "status",
|
||||
stripId: "footer-status",
|
||||
id: "auto-refresh",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid batch tile requests", async () => {
|
||||
const response = await handleDashboardTilesRoute(
|
||||
new Request("https://example.test/api/dashboard/tiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
tiles: [{ kind: "service", id: "missing-group" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("resolves batch tile responses with server concurrency capped at six", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const started: string[] = [];
|
||||
const releases = new Map<string, () => void>();
|
||||
const service = dimensionLabDashboardFixture.serviceGroups
|
||||
.flatMap((group) => group.services)[0];
|
||||
if (!service) throw new Error("missing service fixture");
|
||||
const tiles = Array.from({ length: 7 }, (_, index) => ({
|
||||
kind: "service" as const,
|
||||
groupId: "essentials",
|
||||
id: `service-${index}`,
|
||||
}));
|
||||
|
||||
const batch = loadDashboardTilesResponse(tiles, {
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
tileCache: {
|
||||
async resolve(key) {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
started.push(key);
|
||||
await new Promise<void>((resolve) => releases.set(key, resolve));
|
||||
active -= 1;
|
||||
|
||||
return {
|
||||
cache: "miss",
|
||||
coalesced: false,
|
||||
response: {
|
||||
state: "ready",
|
||||
tile: JSON.parse(key),
|
||||
item: service,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => started.length === 6);
|
||||
expect(maxActive).toBe(6);
|
||||
releases.get(started[0])?.();
|
||||
await waitFor(() => started.length === 7);
|
||||
for (const release of releases.values()) release();
|
||||
|
||||
await expect(batch).resolves.toMatchObject({
|
||||
state: "ready",
|
||||
tiles: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
state: "ready",
|
||||
tile: tiles[0],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(maxActive).toBe(6);
|
||||
});
|
||||
|
||||
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";
|
||||
|
|
@ -598,6 +709,15 @@ function jsonResponse(payload: unknown): Response {
|
|||
});
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
throw new Error("condition was not met");
|
||||
}
|
||||
|
||||
function telemetryFetch() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ const dashboardTileResponseHeaders = {
|
|||
"Cache-Control": "private, max-age=5, stale-while-revalidate=30",
|
||||
};
|
||||
|
||||
const dashboardBatchTileConcurrency = 6;
|
||||
|
||||
const defaultDashboardTileCache = createDashboardTileCache();
|
||||
|
||||
interface DashboardTileCacheResult {
|
||||
|
|
@ -60,6 +62,11 @@ export interface DashboardTileResolutionLogEvent {
|
|||
tileKey: string;
|
||||
}
|
||||
|
||||
export interface DashboardTilesBatchResponse {
|
||||
state: "ready";
|
||||
tiles: DashboardTileResolution[];
|
||||
}
|
||||
|
||||
export function createDashboardTileCache(): DashboardTileCache {
|
||||
const entries = new Map<string, DashboardTileCacheEntry>();
|
||||
const inFlight = new Map<string, Promise<DashboardTileResolution>>();
|
||||
|
|
@ -236,6 +243,16 @@ export async function loadDashboardTileResponse(
|
|||
}
|
||||
}
|
||||
|
||||
export async function loadDashboardTilesResponse(
|
||||
tiles: DashboardTileReference[],
|
||||
options: LoadDashboardResponseOptions = {},
|
||||
): Promise<DashboardTilesBatchResponse> {
|
||||
return {
|
||||
state: "ready",
|
||||
tiles: await resolveDashboardTilesBatch(tiles, options),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleDashboardRoute(): Promise<Response> {
|
||||
return Response.json(await loadDashboardResponse());
|
||||
}
|
||||
|
|
@ -259,6 +276,23 @@ export async function handleDashboardTileRoute(
|
|||
});
|
||||
}
|
||||
|
||||
export async function handleDashboardTilesRoute(
|
||||
request: Request,
|
||||
options: LoadDashboardResponseOptions = {},
|
||||
): Promise<Response> {
|
||||
const tiles = await parseDashboardTilesBatchRequest(request);
|
||||
if (!tiles) {
|
||||
return Response.json(
|
||||
{ ok: false, message: "Invalid dashboard tiles request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(await loadDashboardTilesResponse(tiles, options), {
|
||||
headers: dashboardTileResponseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboardTileCacheKey(tile: DashboardTileReference): string {
|
||||
return JSON.stringify(tile);
|
||||
}
|
||||
|
|
@ -296,6 +330,98 @@ function dashboardTileErrorCategory(error: unknown): string {
|
|||
return "unknown";
|
||||
}
|
||||
|
||||
async function resolveDashboardTilesBatch(
|
||||
tiles: DashboardTileReference[],
|
||||
options: LoadDashboardResponseOptions,
|
||||
): Promise<DashboardTileResolution[]> {
|
||||
const results: DashboardTileResolution[] = new Array(tiles.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < tiles.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await loadDashboardTileResponse(tiles[index], options);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(dashboardBatchTileConcurrency, tiles.length) },
|
||||
() => worker(),
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parseDashboardTilesBatchRequest(
|
||||
request: Request,
|
||||
): Promise<DashboardTileReference[] | null> {
|
||||
try {
|
||||
const body = await request.json() as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("tiles" in body) ||
|
||||
!Array.isArray(body.tiles)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tiles = body.tiles.map(parseDashboardTileReference);
|
||||
return tiles.every((tile): tile is DashboardTileReference => tile !== null)
|
||||
? tiles
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDashboardTileReference(value: unknown): DashboardTileReference | null {
|
||||
if (typeof value !== "object" || value === null || !("kind" in value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
(value.kind === "telemetry" || value.kind === "module") &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string"
|
||||
) {
|
||||
return { kind: value.kind, id: value.id };
|
||||
}
|
||||
|
||||
if (
|
||||
value.kind === "service" &&
|
||||
"groupId" in value &&
|
||||
typeof value.groupId === "string" &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string"
|
||||
) {
|
||||
return {
|
||||
kind: "service",
|
||||
groupId: value.groupId,
|
||||
id: value.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
value.kind === "status" &&
|
||||
"stripId" in value &&
|
||||
typeof value.stripId === "string" &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string"
|
||||
) {
|
||||
return {
|
||||
kind: "status",
|
||||
stripId: value.stripId,
|
||||
id: value.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class DashboardTileCacheResolutionError extends Error {
|
||||
readonly cache: "hit" | "miss";
|
||||
readonly coalesced: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue