829 lines
23 KiB
TypeScript
829 lines
23 KiB
TypeScript
export interface DashboardRefreshPauseState {
|
|
visibilityState: DocumentVisibilityState;
|
|
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 {
|
|
return state.visibilityState !== "visible" || !state.online;
|
|
}
|
|
|
|
export function createDashboardRequestAborter() {
|
|
let shellController: AbortController | undefined;
|
|
let tileController: AbortController | undefined;
|
|
|
|
function abortController(controller: AbortController | undefined) {
|
|
if (controller && !controller.signal.aborted) {
|
|
controller.abort();
|
|
}
|
|
}
|
|
|
|
return {
|
|
beginShellRun(): AbortSignal {
|
|
abortController(shellController);
|
|
abortController(tileController);
|
|
shellController = new AbortController();
|
|
tileController = undefined;
|
|
return shellController.signal;
|
|
},
|
|
beginTileRun(): AbortSignal {
|
|
abortController(tileController);
|
|
tileController = new AbortController();
|
|
return tileController.signal;
|
|
},
|
|
abortActiveRequests(): void {
|
|
abortController(shellController);
|
|
abortController(tileController);
|
|
shellController = undefined;
|
|
tileController = undefined;
|
|
},
|
|
};
|
|
}
|
|
|
|
type DashboardRefreshEventTarget = Pick<
|
|
EventTarget,
|
|
"addEventListener" | "removeEventListener"
|
|
>;
|
|
|
|
export interface DashboardRefreshLifecycleOptions {
|
|
documentTarget: DashboardRefreshEventTarget;
|
|
windowTarget: DashboardRefreshEventTarget;
|
|
loadDashboard: () => void | Promise<void>;
|
|
pauseRefreshes: () => void;
|
|
refreshPaused: () => boolean;
|
|
}
|
|
|
|
export function attachDashboardRefreshLifecycle(
|
|
options: DashboardRefreshLifecycleOptions,
|
|
): () => void {
|
|
function handleRefreshLifecycleChange() {
|
|
if (options.refreshPaused()) {
|
|
options.pauseRefreshes();
|
|
return;
|
|
}
|
|
|
|
void options.loadDashboard();
|
|
}
|
|
|
|
function handlePageHide() {
|
|
options.pauseRefreshes();
|
|
}
|
|
|
|
options.documentTarget.addEventListener(
|
|
"visibilitychange",
|
|
handleRefreshLifecycleChange,
|
|
);
|
|
options.windowTarget.addEventListener("online", handleRefreshLifecycleChange);
|
|
options.windowTarget.addEventListener("offline", handleRefreshLifecycleChange);
|
|
options.windowTarget.addEventListener("pagehide", handlePageHide);
|
|
|
|
return () => {
|
|
options.documentTarget.removeEventListener(
|
|
"visibilitychange",
|
|
handleRefreshLifecycleChange,
|
|
);
|
|
options.windowTarget.removeEventListener("online", handleRefreshLifecycleChange);
|
|
options.windowTarget.removeEventListener("offline", handleRefreshLifecycleChange);
|
|
options.windowTarget.removeEventListener("pagehide", handlePageHide);
|
|
};
|
|
}
|
|
|
|
export interface DashboardHydrationQueueOptions<TItem> {
|
|
concurrency: number;
|
|
hydrate: (item: TItem) => Promise<void> | void;
|
|
items: TItem[];
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
export async function runDashboardHydrationQueue<TItem>(
|
|
options: DashboardHydrationQueueOptions<TItem>,
|
|
): Promise<void> {
|
|
const concurrency = Math.max(1, Math.floor(options.concurrency));
|
|
let nextIndex = 0;
|
|
|
|
async function worker() {
|
|
while (!options.signal.aborted) {
|
|
const item = options.items[nextIndex];
|
|
nextIndex += 1;
|
|
if (item === undefined) return;
|
|
|
|
await options.hydrate(item);
|
|
}
|
|
}
|
|
|
|
const workerCount = Math.min(concurrency, options.items.length);
|
|
await Promise.all(
|
|
Array.from({ length: workerCount }, () => worker()),
|
|
);
|
|
}
|
|
|
|
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;
|
|
signal: AbortSignal;
|
|
waitForIdle: () => Promise<void>;
|
|
}
|
|
|
|
export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
|
options: DashboardViewportHydrationQueueOptions<TItem>,
|
|
): Promise<void> {
|
|
const visibleModelIds = await options.collectVisibleModelIds();
|
|
if (options.signal.aborted) return;
|
|
|
|
const { visible, deferred } = splitDashboardHydrationItemsByVisibility({
|
|
getModelId: options.getModelId,
|
|
items: options.items,
|
|
visibleModelIds,
|
|
});
|
|
|
|
await runDashboardHydrationItems(options, visible);
|
|
if (options.signal.aborted) return;
|
|
options.onVisibleItemsSettled?.();
|
|
|
|
if (!deferred.length) {
|
|
options.onAllItemsSettled?.();
|
|
return;
|
|
}
|
|
|
|
await options.waitForIdle();
|
|
if (options.signal.aborted) return;
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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: {
|
|
getModelId: (item: TItem) => string;
|
|
items: TItem[];
|
|
visibleModelIds: ReadonlySet<string>;
|
|
}): {
|
|
deferred: TItem[];
|
|
visible: TItem[];
|
|
} {
|
|
const visible: TItem[] = [];
|
|
const deferred: TItem[] = [];
|
|
|
|
for (const item of options.items) {
|
|
if (options.visibleModelIds.has(options.getModelId(item))) {
|
|
visible.push(item);
|
|
} else {
|
|
deferred.push(item);
|
|
}
|
|
}
|
|
|
|
return { visible, deferred };
|
|
}
|
|
|
|
export interface DashboardModelElement {
|
|
getAttribute(name: string): string | null;
|
|
}
|
|
|
|
export interface DashboardViewportElementSource {
|
|
querySelectorAll(selector: string): ArrayLike<DashboardModelElement>;
|
|
}
|
|
|
|
export interface DashboardIntersectionEntry {
|
|
intersectionRatio?: number;
|
|
isIntersecting: boolean;
|
|
target: DashboardModelElement;
|
|
}
|
|
|
|
export interface DashboardIntersectionObserver {
|
|
disconnect(): void;
|
|
observe(element: DashboardModelElement): void;
|
|
}
|
|
|
|
export type DashboardIntersectionObserverFactory = (
|
|
callback: (entries: DashboardIntersectionEntry[]) => void,
|
|
) => DashboardIntersectionObserver;
|
|
|
|
type DashboardTimerHandle = ReturnType<typeof setTimeout>;
|
|
|
|
export interface DashboardVisibleModelIdCollectorOptions {
|
|
clearTimeout?: (handle: DashboardTimerHandle) => void;
|
|
createObserver?: DashboardIntersectionObserverFactory;
|
|
documentTarget: DashboardViewportElementSource;
|
|
modelIds: Iterable<string>;
|
|
setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle;
|
|
signal: AbortSignal;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export async function collectVisibleDashboardModelIds(
|
|
options: DashboardVisibleModelIdCollectorOptions,
|
|
): Promise<Set<string>> {
|
|
const targetIds = new Set(options.modelIds);
|
|
if (!targetIds.size || options.signal.aborted) return new Set();
|
|
|
|
const elements = Array.from(
|
|
options.documentTarget.querySelectorAll("[data-model-id]"),
|
|
).filter((element) => {
|
|
const modelId = element.getAttribute("data-model-id");
|
|
return modelId ? targetIds.has(modelId) : false;
|
|
});
|
|
|
|
if (!elements.length) return new Set();
|
|
if (!options.createObserver) return targetIds;
|
|
const createObserver = options.createObserver;
|
|
|
|
const setTimer =
|
|
options.setTimeout ||
|
|
((callback: () => void, timeoutMs: number) =>
|
|
globalThis.setTimeout(callback, timeoutMs));
|
|
const clearTimer =
|
|
options.clearTimeout ||
|
|
((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle));
|
|
const timeoutMs = options.timeoutMs ?? 80;
|
|
|
|
return new Promise((resolve) => {
|
|
const visibleModelIds = new Set<string>();
|
|
let settled = false;
|
|
let timeoutHandle: DashboardTimerHandle | undefined;
|
|
let observer: DashboardIntersectionObserver | undefined;
|
|
|
|
function cleanup() {
|
|
if (timeoutHandle !== undefined) {
|
|
clearTimer(timeoutHandle);
|
|
}
|
|
observer?.disconnect();
|
|
options.signal.removeEventListener("abort", finish);
|
|
}
|
|
|
|
function finish() {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(visibleModelIds);
|
|
}
|
|
|
|
observer = createObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const modelId = entry.target.getAttribute("data-model-id");
|
|
if (
|
|
modelId &&
|
|
targetIds.has(modelId) &&
|
|
(entry.isIntersecting || (entry.intersectionRatio ?? 0) > 0)
|
|
) {
|
|
visibleModelIds.add(modelId);
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const element of elements) {
|
|
observer.observe(element);
|
|
}
|
|
|
|
options.signal.addEventListener("abort", finish, { once: true });
|
|
timeoutHandle = setTimer(finish, timeoutMs);
|
|
});
|
|
}
|
|
|
|
export interface DashboardHydrationIdleOptions {
|
|
cancelIdleCallback?: (handle: number) => void;
|
|
clearTimeout?: (handle: DashboardTimerHandle) => void;
|
|
requestIdleCallback?: (
|
|
callback: () => void,
|
|
options?: { timeout?: number },
|
|
) => number;
|
|
setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle;
|
|
signal: AbortSignal;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export async function waitForDashboardHydrationIdle(
|
|
options: DashboardHydrationIdleOptions,
|
|
): Promise<void> {
|
|
if (options.signal.aborted) return;
|
|
|
|
const setTimer =
|
|
options.setTimeout ||
|
|
((callback: () => void, timeoutMs: number) =>
|
|
globalThis.setTimeout(callback, timeoutMs));
|
|
const clearTimer =
|
|
options.clearTimeout ||
|
|
((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle));
|
|
|
|
await new Promise<void>((resolve) => {
|
|
let settled = false;
|
|
let idleHandle: number | undefined;
|
|
let timeoutHandle: DashboardTimerHandle | undefined;
|
|
|
|
function cleanup() {
|
|
if (idleHandle !== undefined) {
|
|
options.cancelIdleCallback?.(idleHandle);
|
|
}
|
|
if (timeoutHandle !== undefined) {
|
|
clearTimer(timeoutHandle);
|
|
}
|
|
options.signal.removeEventListener("abort", finish);
|
|
}
|
|
|
|
function finish() {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve();
|
|
}
|
|
|
|
options.signal.addEventListener("abort", finish, { once: true });
|
|
if (options.requestIdleCallback) {
|
|
idleHandle = options.requestIdleCallback(finish, {
|
|
timeout: options.timeoutMs ?? 1_000,
|
|
});
|
|
} else {
|
|
timeoutHandle = setTimer(finish, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
export interface DashboardRefreshDelayOptions {
|
|
failureMultiplierLimit?: number;
|
|
jitterRatio?: number;
|
|
random?: () => number;
|
|
}
|
|
|
|
export function createDashboardRefreshDelay(
|
|
options: DashboardRefreshDelayOptions = {},
|
|
) {
|
|
const random = options.random || Math.random;
|
|
const jitterRatio = options.jitterRatio ?? 0.1;
|
|
const failureMultiplierLimit = options.failureMultiplierLimit ?? 8;
|
|
let consecutiveFailures = 0;
|
|
|
|
return {
|
|
nextDelayMs(baseDelayMs: number): number {
|
|
const failureMultiplier = consecutiveFailures
|
|
? Math.min(2 ** consecutiveFailures, failureMultiplierLimit)
|
|
: 1;
|
|
const jitterFactor = 1 + ((random() * 2) - 1) * jitterRatio;
|
|
return Math.max(0, Math.round(baseDelayMs * failureMultiplier * jitterFactor));
|
|
},
|
|
recordFailure(): void {
|
|
consecutiveFailures += 1;
|
|
},
|
|
recordSuccess(): void {
|
|
consecutiveFailures = 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface DashboardPerformanceMarkOptions {
|
|
mark?: (name: string) => void;
|
|
}
|
|
|
|
export const dashboardPerformanceMarks = {
|
|
allTilesSettled: "dashboard:all-tiles-settled",
|
|
firstTileReady: "dashboard:first-tile-ready",
|
|
shellLoad: "dashboard:shell-load",
|
|
visibleTilesReady: "dashboard:visible-tiles-ready",
|
|
} as const;
|
|
|
|
export function createDashboardPerformanceMarks(
|
|
options: DashboardPerformanceMarkOptions = {},
|
|
) {
|
|
const mark = options.mark ||
|
|
globalThis.performance?.mark?.bind(globalThis.performance);
|
|
let firstTileReadyMarked = false;
|
|
|
|
function safeMark(name: string) {
|
|
try {
|
|
mark?.(name);
|
|
} catch {
|
|
// Performance marks are diagnostics only.
|
|
}
|
|
}
|
|
|
|
return {
|
|
markAllTilesSettled(): void {
|
|
safeMark(dashboardPerformanceMarks.allTilesSettled);
|
|
},
|
|
markFirstTileReady(): void {
|
|
if (firstTileReadyMarked) return;
|
|
firstTileReadyMarked = true;
|
|
safeMark(dashboardPerformanceMarks.firstTileReady);
|
|
},
|
|
markShellLoad(): void {
|
|
firstTileReadyMarked = false;
|
|
safeMark(dashboardPerformanceMarks.shellLoad);
|
|
},
|
|
markVisibleTilesReady(): void {
|
|
safeMark(dashboardPerformanceMarks.visibleTilesReady);
|
|
},
|
|
};
|
|
}
|
|
|
|
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
|
|
|
|
export function createDashboardTileBackoff() {
|
|
const failures = new Map<string, { attempts: number; nextAttemptAt: number }>();
|
|
|
|
return {
|
|
canAttempt(key: string, now = Date.now()): boolean {
|
|
const failure = failures.get(key);
|
|
return !failure || now >= failure.nextAttemptAt;
|
|
},
|
|
recordFailure(key: string, now = Date.now()): void {
|
|
const previousAttempts = failures.get(key)?.attempts || 0;
|
|
const attempts = previousAttempts + 1;
|
|
const delay =
|
|
dashboardTileBackoffDelaysMs[
|
|
Math.min(attempts - 1, dashboardTileBackoffDelaysMs.length - 1)
|
|
];
|
|
failures.set(key, {
|
|
attempts,
|
|
nextAttemptAt: now + delay,
|
|
});
|
|
},
|
|
recordSuccess(key: string): void {
|
|
failures.delete(key);
|
|
},
|
|
};
|
|
}
|
|
|
|
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 {
|
|
currentRevisionId: payload.currentRevisionId,
|
|
schemaVersion: payload.schemaVersion,
|
|
tiles: payload.tiles.filter(isDashboardTileSnapshotRecord),
|
|
version: 1,
|
|
};
|
|
} 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 isDashboardTileSnapshotRecord(
|
|
value: unknown,
|
|
): value is DashboardTileSnapshotRecord {
|
|
if (!isSnapshotRecord(value)) return false;
|
|
|
|
return (
|
|
typeof value.savedAt === "number" &&
|
|
Number.isFinite(value.savedAt) &&
|
|
isDashboardTileReference(value.tile) &&
|
|
isDashboardTileSnapshotItem(value.tile, value.item)
|
|
);
|
|
}
|
|
|
|
function isDashboardTileReference(
|
|
value: unknown,
|
|
): value is DashboardTileReference {
|
|
if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false;
|
|
|
|
switch (value.kind) {
|
|
case "telemetry":
|
|
case "module":
|
|
return typeof value.id === "string";
|
|
case "service":
|
|
return typeof value.groupId === "string" && typeof value.id === "string";
|
|
case "status":
|
|
return typeof value.stripId === "string" && typeof value.id === "string";
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isDashboardTileSnapshotItem(
|
|
tile: DashboardTileReference,
|
|
value: unknown,
|
|
): value is DashboardTileSnapshotItem {
|
|
if (!isSnapshotRecord(value) || value.id !== tile.id) return false;
|
|
|
|
switch (tile.kind) {
|
|
case "telemetry":
|
|
return (
|
|
typeof value.label === "string" &&
|
|
isMetricValue(value.value) &&
|
|
isSeverity(value.severity) &&
|
|
isOptionalString(value.detail) &&
|
|
isOptionalString(value.description) &&
|
|
isOptionalString(value.icon) &&
|
|
isOptionalNumberArray(value.sparkline)
|
|
);
|
|
case "service":
|
|
return (
|
|
typeof value.label === "string" &&
|
|
typeof value.description === "string" &&
|
|
isSeverity(value.severity) &&
|
|
isOptionalString(value.detail) &&
|
|
isOptionalString(value.icon) &&
|
|
isOptionalLink(value.link)
|
|
);
|
|
case "module":
|
|
return (
|
|
(value.kind === "summary" ||
|
|
value.kind === "weather" ||
|
|
value.kind === "custom") &&
|
|
isOptionalString(value.title) &&
|
|
isOptionalString(value.label) &&
|
|
isOptionalString(value.value) &&
|
|
isOptionalString(value.detail) &&
|
|
isOptionalString(value.icon) &&
|
|
(value.severity === undefined || isSeverity(value.severity))
|
|
);
|
|
case "status":
|
|
return (
|
|
typeof value.label === "string" &&
|
|
typeof value.value === "string" &&
|
|
isOptionalLink(value.link) &&
|
|
(value.severity === undefined || isSeverity(value.severity))
|
|
);
|
|
}
|
|
}
|
|
|
|
function isSnapshotRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isMetricValue(value: unknown): boolean {
|
|
if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false;
|
|
|
|
if (value.kind === "text") {
|
|
return (
|
|
typeof value.value === "string" &&
|
|
isOptionalString(value.unit)
|
|
);
|
|
}
|
|
|
|
const numericKinds = ["bytes", "latency", "number", "percent", "temperature"];
|
|
if (!numericKinds.includes(value.kind)) return false;
|
|
if (typeof value.value !== "number" || !Number.isFinite(value.value)) return false;
|
|
if (value.kind === "percent" && (value.value < 0 || value.value > 100)) {
|
|
return false;
|
|
}
|
|
|
|
const precision = value.precision;
|
|
return (
|
|
isOptionalString(value.unit) &&
|
|
(precision === undefined ||
|
|
(typeof precision === "number" &&
|
|
Number.isInteger(precision) &&
|
|
precision >= 0 &&
|
|
precision <= 4))
|
|
);
|
|
}
|
|
|
|
function isSeverity(value: unknown): boolean {
|
|
return (
|
|
value === "neutral" ||
|
|
value === "ok" ||
|
|
value === "warning" ||
|
|
value === "danger" ||
|
|
value === "stale" ||
|
|
value === "unavailable"
|
|
);
|
|
}
|
|
|
|
function isOptionalString(value: unknown): boolean {
|
|
return value === undefined || typeof value === "string";
|
|
}
|
|
|
|
function isOptionalNumberArray(value: unknown): boolean {
|
|
return (
|
|
value === undefined ||
|
|
(Array.isArray(value) &&
|
|
value.every((item) => typeof item === "number" && Number.isFinite(item)))
|
|
);
|
|
}
|
|
|
|
function isOptionalLink(value: unknown): boolean {
|
|
return (
|
|
value === undefined ||
|
|
(isSnapshotRecord(value) &&
|
|
typeof value.href === "string" &&
|
|
isOptionalString(value.label) &&
|
|
(value.external === undefined || typeof value.external === "boolean"))
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|