843 lines
23 KiB
TypeScript
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}`;
|
|
}
|