dimensionlab-website/apps/web/src/lib/server/datasources/index.ts

843 lines
23 KiB
TypeScript

import type {
DashboardDocument,
DashboardModule,
MetricValue,
ServiceEntry,
ServiceGroup,
Severity,
StatusItem,
StatusStrip,
TelemetryCard,
} from "@dimensionlab/dashboard-model";
export type DashboardTileReference =
| { kind: "telemetry"; id: string }
| { kind: "service"; groupId: string; id: string }
| { kind: "module"; id: string }
| { kind: "status"; stripId: string; id: string };
export type DashboardTileItem =
| DashboardModule
| ServiceEntry
| StatusItem
| TelemetryCard;
export type DashboardTileResolution =
| {
state: "ready";
tile: DashboardTileReference;
item: DashboardTileItem;
}
| {
state: "not_found";
tile: DashboardTileReference;
message: string;
}
| {
state: "disabled";
tile: DashboardTileReference;
message: string;
};
export interface DatasourceResolutionOptions {
fetch?: DatasourceFetch;
prometheusBaseUrl?: string;
prometheusRangeSeconds?: number;
prometheusStepSeconds?: number;
requestTimeoutMs?: number;
}
export async function resolveDashboardDatasources(
document: DashboardDocument,
options: DatasourceResolutionOptions = {},
): Promise<DashboardDocument> {
const context = datasourceContext(options);
const telemetry = await Promise.all(
document.telemetry.map((card) => resolveTelemetryCard(card, context)),
);
const serviceGroups = await Promise.all(
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
);
const modules = await resolveModules(document.modules || [], serviceGroups, context);
const statusStrips = await resolveStatusStrips(
document.statusStrips,
document.metadata.refreshIntervalSeconds,
serviceGroups,
context,
);
return {
...structuredClone(document),
telemetry,
serviceGroups,
modules,
statusStrips,
};
}
export async function resolveDashboardTile(
document: DashboardDocument,
tile: DashboardTileReference,
options: DatasourceResolutionOptions = {},
): Promise<DashboardTileResolution> {
const context = datasourceContext(options);
if (tile.kind === "telemetry") {
const card = document.telemetry.find((item) => item.id === tile.id);
if (!card) return missingTile(tile);
return {
state: "ready",
tile,
item: await resolveTelemetryCard(card, context),
};
}
if (tile.kind === "service") {
const service = document.serviceGroups
.find((group) => group.id === tile.groupId)
?.services.find((item) => item.id === tile.id);
if (!service) return missingTile(tile);
return {
state: "ready",
tile,
item: await resolveService(service, context),
};
}
if (tile.kind === "module") {
const module = document.modules?.find((item) => item.id === tile.id);
if (!module) return missingTile(tile);
const item = module.id === "runtime-health-summary"
? runtimeHealthSummary(
module,
await Promise.all(
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
),
)
: await resolveModule(module, context);
return { state: "ready", tile, item };
}
const strip = document.statusStrips.find((item) => item.id === tile.stripId);
const statusItem = strip?.items.find((item) => item.id === tile.id);
if (!strip || !statusItem) return missingTile(tile);
return {
state: "ready",
tile,
item: await resolveStatusTile(
statusItem,
document.metadata.refreshIntervalSeconds,
document.serviceGroups,
context,
),
};
}
interface DatasourceContext {
fetch: DatasourceFetch;
prometheusBaseUrl: string;
prometheusRangeSeconds: number;
prometheusStepSeconds: number;
requestTimeoutMs: number;
}
type DatasourceFetch = (input: string, init?: RequestInit) => Promise<Response>;
interface PrometheusVectorResult {
metric?: Record<string, string>;
value?: [number, string];
}
interface PrometheusMatrixResult {
metric?: Record<string, string>;
values?: Array<[number, string]>;
}
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
return {
fetch: options.fetch || globalThis.fetch,
prometheusBaseUrl:
options.prometheusBaseUrl ||
process.env.PROMETHEUS_BASE_URL ||
"https://prometheus.dimensionlab.net",
prometheusRangeSeconds: options.prometheusRangeSeconds || 60 * 60,
prometheusStepSeconds: options.prometheusStepSeconds || 120,
requestTimeoutMs: options.requestTimeoutMs || 2_500,
};
}
async function resolveTelemetryCard(
card: TelemetryCard,
context: DatasourceContext,
): Promise<TelemetryCard> {
if (card.datasource?.type !== "external" || card.datasource.adapter !== "prometheus") {
return structuredClone(card);
}
try {
const [instant, range] = await Promise.all([
prometheusQuery(card.datasource.reference, context),
prometheusRangeQuery(card.datasource.reference, context),
]);
const current = pickMaxVectorResult(instant);
const currentValue = Number(current?.value?.[1]);
const sparkline = matrixPoints(range);
if (!Number.isFinite(currentValue)) return markStale(card, "no telemetry data");
return {
...structuredClone(card),
value: metricValueWithLiveNumber(card.value, currentValue),
severity: severityForValue(currentValue, card.thresholds),
detail: prometheusMetricDetail(current?.metric || {}, card.detail),
sparkline: sparkline.length ? sparkline : card.sparkline,
};
} catch {
return markStale(card, card.detail || "telemetry unavailable");
}
}
async function resolveServiceGroup(
group: ServiceGroup,
context: DatasourceContext,
): Promise<ServiceGroup> {
return {
...structuredClone(group),
services: await Promise.all(
group.services.map((service) => resolveService(service, context)),
),
};
}
async function resolveService(
service: ServiceEntry,
context: DatasourceContext,
): Promise<ServiceEntry> {
const datasource = service.datasource;
if (datasource?.type !== "external") return structuredClone(service);
if (datasource.adapter === "http-status") {
return resolveHttpStatusService(service, datasource.reference, context);
}
if (datasource.adapter === "prometheus") {
return resolvePrometheusService(service, datasource.reference, context);
}
return structuredClone(service);
}
async function resolveHttpStatusService(
service: ServiceEntry,
reference: string,
context: DatasourceContext,
): Promise<ServiceEntry> {
const request = parseHttpStatusReference(reference);
if (!request) return structuredClone(service);
try {
const startedAt = Date.now();
const response = await fetchWithTimeout(
context.fetch,
request.url,
{
cache: "no-store",
method: request.method,
},
context.requestTimeoutMs,
);
const elapsedMs = Math.max(0, Math.round(Date.now() - startedAt));
const badge = await uptimeBadge(response);
if (badge) {
const ping = badge.ping;
const status = badge.status || "UNKNOWN";
return {
...structuredClone(service),
severity: uptimeBadgeSeverity(status),
detail: Number.isFinite(ping)
? `${Math.round(ping as number)} ms`
: status.toLowerCase(),
};
}
return {
...structuredClone(service),
severity: response.ok ? "ok" : response.status >= 500 ? "danger" : "warning",
detail: response.ok ? `${elapsedMs} ms` : `HTTP ${response.status}`,
};
} catch {
return {
...structuredClone(service),
severity: "unavailable",
detail: "unavailable",
};
}
}
async function resolvePrometheusService(
service: ServiceEntry,
reference: string,
context: DatasourceContext,
): Promise<ServiceEntry> {
try {
const result = pickMaxVectorResult(await prometheusQuery(reference, context));
const value = Number(result?.value?.[1]);
const ok = Number.isFinite(value) && value > 0;
return {
...structuredClone(service),
severity: ok ? "ok" : "unavailable",
detail: ok ? "up" : "down",
};
} catch {
return {
...structuredClone(service),
severity: "unavailable",
detail: "unavailable",
};
}
}
async function resolveModules(
modules: DashboardModule[],
serviceGroups: ServiceGroup[],
context: DatasourceContext,
): Promise<DashboardModule[]> {
const resolved = await Promise.all(
modules.map((module) => resolveModule(module, context)),
);
return resolved.map((module) =>
module.id === "runtime-health-summary"
? runtimeHealthSummary(module, serviceGroups)
: module,
);
}
async function resolveModule(
module: DashboardModule,
context: DatasourceContext,
): Promise<DashboardModule> {
if (module.datasource?.type !== "external" || module.datasource.adapter !== "weather") {
return structuredClone(module);
}
try {
const weather = await requestJson<OpenMeteoResponse>(
context.fetch,
openMeteoUrl(module.datasource.reference),
context.requestTimeoutMs,
);
const current = weather.current;
const temperature = Number(current?.temperature_2m);
if (!Number.isFinite(temperature)) {
return {
...structuredClone(module),
severity: "stale",
detail: "weather unavailable",
};
}
const apparent = Number(current?.apparent_temperature);
const wind = Number(current?.wind_speed_10m);
const condition = weatherCondition(Number(current?.weather_code));
return {
...structuredClone(module),
value: `${temperature.toFixed(1)} C`,
detail: [
condition,
Number.isFinite(apparent) ? `feels ${apparent.toFixed(1)} C` : "",
Number.isFinite(wind) ? `wind ${Math.round(wind)} km/h` : "",
].filter(Boolean).join(" - "),
severity: "ok",
};
} catch {
return {
...structuredClone(module),
severity: "stale",
detail: "weather unavailable",
};
}
}
function runtimeHealthSummary(
module: DashboardModule,
serviceGroups: ServiceGroup[],
): DashboardModule {
const services = serviceGroups.flatMap((group) => group.services);
const down = services.filter((service) =>
service.severity === "danger" || service.severity === "unavailable"
).length;
const warning = services.filter((service) => service.severity === "warning").length;
const ok = services.filter((service) => service.severity === "ok").length;
if (down > 0) {
return {
...structuredClone(module),
value: `${down} service${down === 1 ? "" : "s"} down`,
detail: `${warning} warning${warning === 1 ? "" : "s"} - ${ok} service${ok === 1 ? "" : "s"} ok`,
severity: "danger",
};
}
if (warning > 0) {
return {
...structuredClone(module),
value: `${warning} service${warning === 1 ? "" : "s"} warning`,
detail: `${ok} service${ok === 1 ? "" : "s"} ok`,
severity: "warning",
};
}
return {
...structuredClone(module),
value: "all systems operational",
detail: `${ok} service${ok === 1 ? "" : "s"} ok`,
severity: "ok",
};
}
async function resolveStatusStrips(
strips: StatusStrip[],
refreshIntervalSeconds: number | undefined,
serviceGroups: ServiceGroup[],
context: DatasourceContext,
): Promise<StatusStrip[]> {
const [uptime, loadAverage] = await Promise.all([
prometheusScalar(
'time() - node_boot_time_seconds{job="node",host="linux-infra"}',
context,
).catch(() => null),
prometheusLoadAverage(context).catch(() => null),
]);
const health = serviceHealthSummary(serviceGroups);
return strips.map((strip) => ({
...structuredClone(strip),
items: strip.items.map((item) =>
resolveStatusItem(item, {
health,
loadAverage,
refreshIntervalSeconds,
uptime,
}),
),
}));
}
function resolveStatusItem(
item: StatusItem,
values: {
health: { severity: Severity; value: string };
loadAverage: string | null;
refreshIntervalSeconds?: number;
uptime: number | null;
},
): StatusItem {
if (item.id === "system-status") {
return {
...structuredClone(item),
value: values.health.value,
severity: values.health.severity,
};
}
if (item.id === "last-sync") {
return {
...structuredClone(item),
value: "just now",
severity: "ok",
};
}
if (item.id === "uptime" && values.uptime !== null) {
return {
...structuredClone(item),
value: formatDuration(values.uptime),
severity: "ok",
};
}
if (item.id === "load-avg" && values.loadAverage) {
return {
...structuredClone(item),
value: values.loadAverage,
severity: "neutral",
};
}
if (item.id === "auto-refresh" && values.refreshIntervalSeconds) {
return {
...structuredClone(item),
value: `${values.refreshIntervalSeconds}s`,
severity: "neutral",
};
}
return structuredClone(item);
}
async function resolveStatusTile(
item: StatusItem,
refreshIntervalSeconds: number | undefined,
serviceGroups: ServiceGroup[],
context: DatasourceContext,
): Promise<StatusItem> {
if (item.id === "system-status") {
const resolvedGroups = await Promise.all(
serviceGroups.map((group) => resolveServiceGroup(group, context)),
);
const health = serviceHealthSummary(resolvedGroups);
return {
...structuredClone(item),
value: health.value,
severity: health.severity,
};
}
if (item.id === "last-sync") {
return {
...structuredClone(item),
value: "just now",
severity: "ok",
};
}
if (item.id === "uptime") {
const uptime = await prometheusScalar(
'time() - node_boot_time_seconds{job="node",host="linux-infra"}',
context,
).catch(() => null);
return uptime === null
? structuredClone(item)
: {
...structuredClone(item),
value: formatDuration(uptime),
severity: "ok",
};
}
if (item.id === "load-avg") {
const loadAverage = await prometheusLoadAverage(context).catch(() => null);
return loadAverage
? {
...structuredClone(item),
value: loadAverage,
severity: "neutral",
}
: structuredClone(item);
}
if (item.id === "auto-refresh" && refreshIntervalSeconds) {
return {
...structuredClone(item),
value: `${refreshIntervalSeconds}s`,
severity: "neutral",
};
}
return structuredClone(item);
}
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
severity: Severity;
value: string;
} {
const services = serviceGroups.flatMap((group) => group.services);
const down = services.filter((service) =>
service.severity === "danger" || service.severity === "unavailable"
).length;
const warning = services.filter((service) => service.severity === "warning").length;
if (down > 0) {
return {
severity: "danger",
value: `${down} service${down === 1 ? "" : "s"} down`,
};
}
if (warning > 0) {
return {
severity: "warning",
value: `${warning} service${warning === 1 ? "" : "s"} warning`,
};
}
return {
severity: "ok",
value: "All systems operational",
};
}
async function prometheusQuery(
query: string,
context: DatasourceContext,
): Promise<PrometheusVectorResult[]> {
const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query", {
query,
});
const payload = await requestJson<PrometheusResponse<PrometheusVectorResult>>(
context.fetch,
url,
context.requestTimeoutMs,
);
return payload.status === "success" ? payload.data.result || [] : [];
}
async function prometheusRangeQuery(
query: string,
context: DatasourceContext,
): Promise<PrometheusMatrixResult[]> {
const end = Math.floor(Date.now() / 1000);
const start = end - context.prometheusRangeSeconds;
const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query_range", {
query,
start: String(start),
end: String(end),
step: String(context.prometheusStepSeconds),
});
const payload = await requestJson<PrometheusResponse<PrometheusMatrixResult>>(
context.fetch,
url,
context.requestTimeoutMs,
);
return payload.status === "success" ? payload.data.result || [] : [];
}
async function prometheusScalar(
query: string,
context: DatasourceContext,
): Promise<number | null> {
const result = pickMaxVectorResult(await prometheusQuery(query, context));
const value = Number(result?.value?.[1]);
return Number.isFinite(value) ? value : null;
}
async function prometheusLoadAverage(context: DatasourceContext): Promise<string | null> {
const [one, five, fifteen] = await Promise.all([
prometheusScalar('node_load1{job="node",host="linux-infra"}', context),
prometheusScalar('node_load5{job="node",host="linux-infra"}', context),
prometheusScalar('node_load15{job="node",host="linux-infra"}', context),
]);
if (one === null || five === null || fifteen === null) return null;
return [one, five, fifteen].map((value) => value.toFixed(2)).join(" ");
}
async function requestJson<T>(
fetch: DatasourceFetch,
url: string,
timeoutMs: number,
): Promise<T> {
const response = await fetchWithTimeout(
fetch,
url,
{ cache: "no-store" },
timeoutMs,
);
if (!response.ok) throw new Error(`request failed: ${response.status}`);
return response.json() as Promise<T>;
}
async function fetchWithTimeout(
fetch: DatasourceFetch,
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function prometheusApiUrl(
baseUrl: string,
pathname: string,
params: Record<string, string>,
): string {
const url = new URL(pathname, ensureTrailingSlash(baseUrl));
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
return url.toString();
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}
function pickMaxVectorResult(
results: PrometheusVectorResult[],
): PrometheusVectorResult | null {
return results.reduce<PrometheusVectorResult | null>((winner, item) => {
if (!winner) return item;
return Number(item.value?.[1]) > Number(winner.value?.[1]) ? item : winner;
}, null);
}
function matrixPoints(results: PrometheusMatrixResult[]): number[] {
const byTimestamp = new Map<number, number>();
for (const series of results) {
for (const [timestamp, value] of series.values || []) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) continue;
const previous = byTimestamp.get(timestamp);
if (previous === undefined || numeric > previous) {
byTimestamp.set(timestamp, numeric);
}
}
}
return [...byTimestamp.entries()]
.sort(([left], [right]) => left - right)
.map(([, value]) => value);
}
function metricValueWithLiveNumber(value: MetricValue, nextValue: number): MetricValue {
if (value.kind === "text") return value;
return {
...value,
value: value.kind === "percent" ? clamp(nextValue, 0, 100) : Math.max(0, nextValue),
};
}
function severityForValue(
value: number,
thresholds: TelemetryCard["thresholds"],
): Severity {
if (thresholds?.danger !== undefined && value >= thresholds.danger) return "danger";
if (thresholds?.warning !== undefined && value >= thresholds.warning) return "warning";
return "ok";
}
function prometheusMetricDetail(
metric: Record<string, string>,
fallback = "",
): string {
const host = metric.host || metric.instance || metric.job;
const detail = metric.mountpoint || metric.name || metric.container || metric.id;
const parts = [host, detail].filter(Boolean);
if (parts.length) return parts.join(" ");
return fallback.replace(/^fallback\s*-\s*/i, "") || "telemetry";
}
function markStale(card: TelemetryCard, detail: string): TelemetryCard {
return {
...structuredClone(card),
severity: "stale",
detail,
};
}
function parseHttpStatusReference(reference: string): { method: string; url: string } | null {
const match = /^(GET|HEAD|POST)\s+(.+)$/i.exec(reference.trim());
if (!match) return null;
return { method: match[1].toUpperCase(), url: match[2] };
}
interface PrometheusResponse<T> {
status: string;
data: {
result?: T[];
};
}
interface OpenMeteoResponse {
current?: {
apparent_temperature?: number;
temperature_2m?: number;
weather_code?: number;
wind_speed_10m?: number;
};
}
interface UptimeBadgeResponse {
status?: string;
ping?: number;
}
async function uptimeBadge(response: Response): Promise<UptimeBadgeResponse | null> {
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("json")) return null;
try {
const payload = await response.clone().json() as UptimeBadgeResponse;
return typeof payload.status === "string" ? payload : null;
} catch {
return null;
}
}
function uptimeBadgeSeverity(status = ""): Severity {
const normalized = status.toUpperCase();
if (normalized === "UP") return "ok";
if (normalized === "PENDING" || normalized === "MAINTENANCE") return "warning";
return "unavailable";
}
function openMeteoUrl(reference: string): string {
const params = new URLSearchParams();
const serialized = reference.startsWith("open-meteo:")
? reference.slice("open-meteo:".length)
: reference;
new URLSearchParams(serialized).forEach((value, key) => {
params.set(key, value);
});
params.set(
"current",
"temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
);
params.set("timezone", "Europe/Amsterdam");
return `https://api.open-meteo.com/v1/forecast?${params.toString()}`;
}
function weatherCondition(code: number): string {
if (code === 0) return "Clear";
if ([1, 2].includes(code)) return "Partly cloudy";
if (code === 3) return "Cloudy";
if ([45, 48].includes(code)) return "Fog";
if (code >= 51 && code <= 67) return "Rain";
if (code >= 71 && code <= 77) return "Snow";
if (code >= 80 && code <= 82) return "Showers";
if (code >= 95) return "Thunderstorm";
return "Mixed";
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function formatDuration(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds));
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
function missingTile(tile: DashboardTileReference): DashboardTileResolution {
return {
state: "not_found",
tile,
message: `Dashboard tile not found: ${tileKey(tile)}`,
};
}
function tileKey(tile: DashboardTileReference): string {
if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
return `${tile.kind}:${tile.id}`;
}