feat(dashboard): add live observability overview

This commit is contained in:
vince 2026-06-19 03:38:38 +02:00
parent 2ff9f3c0ed
commit 2d2b905d18
27 changed files with 1417 additions and 144 deletions

View file

@ -10,6 +10,7 @@
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"drizzle-orm": "^0.45.2",
"uplot": "^1.6.32",
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
@ -735,6 +736,8 @@
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
"uplot": ["uplot@1.6.32", "", {}, "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],

View file

@ -22,7 +22,8 @@
"@sinclair/typebox": "^0.34.49",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"drizzle-orm": "^0.45.2"
"drizzle-orm": "^0.45.2",
"uplot": "^1.6.32"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",

View file

@ -18,7 +18,7 @@ export default defineConfig({
screenshot: "only-on-failure",
},
webServer: {
command: `bun run build && DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`,
command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,

View file

@ -128,9 +128,11 @@ const verifiedSeedIconIds = new Set([
"mdi:pulse",
"mdi:robot-outline",
"mdi:router-network",
"mdi:text-box-search",
"mdi:thermometer",
"mdi:web",
"mdi:weather-sunny",
"simple-icons:adguard",
"simple-icons:adminer",
"simple-icons:amazonwebservices",
"simple-icons:cockpit",

View file

@ -36,11 +36,35 @@ interface ServiceSeed {
detail?: string;
}
const REAL_FILESYSTEM_FILTER =
'fstype!~"tmpfs|overlay|squashfs|nsfs|tracefs|autofs|proc|sysfs|cgroup2|devtmpfs|securityfs|debugfs|pstore|bpf|configfs|selinuxfs|mqueue|hugetlbfs|fusectl|ramfs"';
const USER_MOUNT_FILTER =
'mountpoint=~"^/home($|/)|^/srv($|/)|^/data($|/)|^/mnt($|/)|^/media($|/)",mountpoint!~"^/(boot|boot/efi|efi|var|usr|opt|run|dev|proc|sys)($|/)"';
const SYSTEM_MOUNT_FILTER =
'mountpoint=~"^/$|^/boot($|/)|^/boot/efi$|^/var($|/)|^/usr($|/)|^/opt($|/)",mountpoint!~"^/home($|/)|^/srv($|/)|^/data($|/)|^/mnt($|/)|^/media($|/)"';
const diskUsedQuery = (mountFilter: string) => {
const selector = `job="node",${REAL_FILESYSTEM_FILTER},${mountFilter}`;
return `topk(1, max by (host, mountpoint) (100 * (1 - node_filesystem_avail_bytes{${selector}} / node_filesystem_size_bytes{${selector}})))`;
};
const ramQuery = (host: string) =>
`100 * (1 - node_memory_MemAvailable_bytes{job="node",host="${host}"} / node_memory_MemTotal_bytes{job="node",host="${host}"})`;
const gpuQuery = (name: string, metric: string) =>
`${metric}{job="node",name=~".*${name}.*"}`;
const gpuVramQuery = (name: string) =>
`100 * nvidia_gpu_memory_used_bytes{job="node",name=~".*${name}.*"} / nvidia_gpu_memory_total_bytes{job="node",name=~".*${name}.*"}`;
const hostCpuQuery =
'topk(1, 100 * (1 - avg by (host) (rate(node_cpu_seconds_total{job="node",mode="idle"}[5m]))))';
const topCpuQuery =
'topk(1, 100 * rate(podman_container_cpu_seconds_total{job=~"podman-.*"}[5m]) * on(host,id) group_left(name) podman_container_info{job=~"podman-.*"})';
const topRamQuery =
'topk(1, podman_container_mem_usage_bytes{job=~"podman-.*"} * on(host,id) group_left(name) podman_container_info{job=~"podman-.*"})';
export const dimensionLabDashboardFixture: DashboardDocument = {
schemaVersion: DASHBOARD_SCHEMA_VERSION,
metadata: {
title: "System Overview",
subtitle: "Dimension Lab home infra, GPU, and automation surface",
subtitle: "Capacity, noise & top consumers",
description:
"Initial Dimension Lab dashboard seed data. Runtime values are fallback values until datasource adapters are enabled.",
timezone: "Europe/Amsterdam",
@ -81,7 +105,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(),
datasource: prometheus(
'100 * (1 - node_memory_MemAvailable_bytes{instance="linux-infra"} / node_memory_MemTotal_bytes{instance="linux-infra"})',
ramQuery("linux-infra"),
),
sparkline: [18, 18, 19, 19, 18, 19],
}),
@ -95,7 +119,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(),
datasource: prometheus(
'100 * (1 - node_memory_MemAvailable_bytes{instance="linux-gpu"} / node_memory_MemTotal_bytes{instance="linux-gpu"})',
ramQuery("linux"),
),
sparkline: [16, 16, 17, 17, 17, 17],
}),
@ -109,7 +133,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(),
datasource: prometheus(
'100 * (1 - node_memory_MemAvailable_bytes{instance="network-core"} / node_memory_MemTotal_bytes{instance="network-core"})',
ramQuery("network-core"),
),
sparkline: [5, 5, 5, 6, 5, 5],
}),
@ -123,7 +147,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(80, 92),
datasource: prometheus(
'100 * (1 - node_filesystem_avail_bytes{mountpoint="/home"} / node_filesystem_size_bytes{mountpoint="/home"})',
diskUsedQuery(USER_MOUNT_FILTER),
),
sparkline: [27, 27, 28, 29, 29, 29],
}),
@ -137,7 +161,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(80, 92),
datasource: prometheus(
'100 * (1 - node_filesystem_avail_bytes{mountpoint="/boot"} / node_filesystem_size_bytes{mountpoint="/boot"})',
diskUsedQuery(SYSTEM_MOUNT_FILTER),
),
sparkline: [48, 48, 49, 49, 49, 49],
}),
@ -151,7 +175,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(75, 90),
datasource: prometheus(
'100 - (avg by(instance) (rate(node_cpu_seconds_total{instance="linux-infra",mode="idle"}[5m])) * 100)',
hostCpuQuery,
),
sparkline: [2, 3, 3, 4, 3, 3],
}),
@ -165,9 +189,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - top container CPU pending label mapping",
severity: "ok",
thresholds: percentThresholds(70, 90),
datasource: placeholder(
"fallback top-container CPU query pending container label normalization",
),
datasource: prometheus(topCpuQuery),
sparkline: [6.1, 6.4, 7.0, 7.5, 7.2, 7.9],
}),
metric({
@ -180,9 +202,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - top container RAM pending label mapping",
severity: "danger",
thresholds: { warning: 3 * 1024 ** 3, danger: 5 * 1024 ** 3 },
datasource: placeholder(
"fallback top-container memory query pending container label normalization",
),
datasource: prometheus(topRamQuery),
sparkline: [
3.1 * 1024 ** 3,
3.5 * 1024 ** 3,
@ -201,7 +221,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - RTX 3060 GPU utilization",
severity: "ok",
thresholds: percentThresholds(85, 95),
datasource: prometheus('DCGM_FI_DEV_GPU_UTIL{gpu="rtx-3060"}'),
datasource: prometheus(gpuQuery("3060", "nvidia_gpu_utilization_percent")),
sparkline: [0, 0, 0, 0, 0, 0],
}),
metric({
@ -214,7 +234,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "ok",
thresholds: percentThresholds(80, 92),
datasource: prometheus(
'100 * DCGM_FI_DEV_FB_USED{gpu="rtx-3060"} / (DCGM_FI_DEV_FB_USED{gpu="rtx-3060"} + DCGM_FI_DEV_FB_FREE{gpu="rtx-3060"})',
gpuVramQuery("3060"),
),
sparkline: [0, 0, 0, 0, 0, 0],
}),
@ -227,7 +247,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - RTX 3060 temperature",
severity: "ok",
thresholds: { warning: 75, danger: 85 },
datasource: prometheus('DCGM_FI_DEV_GPU_TEMP{gpu="rtx-3060"}'),
datasource: prometheus(gpuQuery("3060", "nvidia_gpu_temperature_celsius")),
sparkline: [52, 53, 54, 54, 53, 54],
}),
metric({
@ -239,7 +259,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - RTX 3060 fan duty",
severity: "ok",
thresholds: percentThresholds(80, 95),
datasource: prometheus('DCGM_FI_DEV_FAN_SPEED{gpu="rtx-3060"}'),
datasource: prometheus(gpuQuery("3060", "nvidia_gpu_fan_speed_percent")),
sparkline: [0, 0, 0, 0, 0, 0],
}),
metric({
@ -251,7 +271,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - RTX 3090 GPU utilization",
severity: "ok",
thresholds: percentThresholds(85, 95),
datasource: prometheus('DCGM_FI_DEV_GPU_UTIL{gpu="rtx-3090"}'),
datasource: prometheus(gpuQuery("3090", "nvidia_gpu_utilization_percent")),
sparkline: [0, 0, 0, 0, 0, 0],
}),
metric({
@ -264,7 +284,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
severity: "warning",
thresholds: percentThresholds(70, 90),
datasource: prometheus(
'100 * DCGM_FI_DEV_FB_USED{gpu="rtx-3090"} / (DCGM_FI_DEV_FB_USED{gpu="rtx-3090"} + DCGM_FI_DEV_FB_FREE{gpu="rtx-3090"})',
gpuVramQuery("3090"),
),
sparkline: [68, 70, 72, 74, 73, 74],
}),
@ -277,7 +297,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - RTX 3090 temperature",
severity: "ok",
thresholds: { warning: 75, danger: 85 },
datasource: prometheus('DCGM_FI_DEV_GPU_TEMP{gpu="rtx-3090"}'),
datasource: prometheus(gpuQuery("3090", "nvidia_gpu_temperature_celsius")),
sparkline: [49, 50, 50, 51, 50, 50],
}),
metric({
@ -289,7 +309,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
detail: "fallback - RTX 3090 fan duty",
severity: "ok",
thresholds: percentThresholds(80, 95),
datasource: prometheus('DCGM_FI_DEV_FAN_SPEED{gpu="rtx-3090"}'),
datasource: prometheus(gpuQuery("3090", "nvidia_gpu_fan_speed_percent")),
sparkline: [0, 0, 0, 0, 0, 0],
}),
],
@ -301,7 +321,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Password manager",
icon: "simple-icons:vaultwarden",
href: "https://vault.dimensionlab.net",
datasource: httpStatus("https://vault.dimensionlab.net/alive"),
datasource: uptimeMonitor(1),
}),
service({
id: "forgejo",
@ -309,7 +329,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Git repositories",
icon: "simple-icons:forgejo",
href: "https://git.dimensionlab.net",
datasource: httpStatus("https://git.dimensionlab.net/api/healthz"),
datasource: uptimeMonitor(2),
}),
service({
id: "wiki",
@ -317,7 +337,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Internal documentation",
icon: "simple-icons:wikidotjs",
href: "https://wiki.dimensionlab.net",
datasource: httpStatus("https://wiki.dimensionlab.net"),
datasource: uptimeMonitor(3),
}),
service({
id: "aws-start",
@ -325,7 +345,23 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "AWS access portal",
icon: "simple-icons:amazonwebservices",
href: "https://dimensionlab.awsapps.com/start",
datasource: httpStatus("https://dimensionlab.awsapps.com/start"),
datasource: uptimeMonitor(21),
}),
service({
id: "adguard-primary",
label: "AdGuard Primary",
description: "DNS filtering and DHCP",
icon: "simple-icons:adguard",
href: "https://control.dimensionlab.net",
datasource: uptimeMonitor(14),
}),
service({
id: "adguard-secondary",
label: "AdGuard Secondary",
description: "Fallback DNS",
icon: "simple-icons:adguard",
href: "https://control-secondary.dimensionlab.net",
datasource: uptimeMonitor(18),
}),
]),
group("monitoring", "Monitoring", [
@ -335,7 +371,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Capacity and noise dashboard",
icon: "simple-icons:grafana",
href: "https://grafana.dimensionlab.net",
datasource: httpStatus("https://grafana.dimensionlab.net/api/health"),
datasource: uptimeMonitor(11),
}),
service({
id: "uptime-kuma",
@ -343,7 +379,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Service uptime checks",
icon: "simple-icons:uptimekuma",
href: "https://uptime.dimensionlab.net",
datasource: httpStatus("https://uptime.dimensionlab.net/status/dimensionlab"),
datasource: uptimeMonitor(10),
}),
service({
id: "prometheus",
@ -351,7 +387,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Metrics database",
icon: "simple-icons:prometheus",
href: "https://prometheus.dimensionlab.net",
datasource: httpStatus("https://prometheus.dimensionlab.net/-/ready"),
datasource: uptimeMonitor(12),
}),
service({
id: "backrest",
@ -359,7 +395,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Restic backup manager",
icon: "mdi:backup-restore",
href: "https://backups.dimensionlab.net",
datasource: httpStatus("https://backups.dimensionlab.net"),
datasource: uptimeMonitor(13),
}),
]),
group("ai-automation", "AI & Automation", [
@ -369,7 +405,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Workflow automation",
icon: "simple-icons:n8n",
href: "https://workflows.dimensionlab.net",
datasource: httpStatus("https://workflows.dimensionlab.net/healthz"),
datasource: uptimeMonitor(4),
}),
service({
id: "open-webui",
@ -377,7 +413,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Chat and model interface",
icon: "mdi:web",
href: "https://webui.dimensionlab.net",
datasource: httpStatus("https://webui.dimensionlab.net/health"),
datasource: uptimeMonitor(5),
}),
service({
id: "comfyui",
@ -385,7 +421,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Image generation workflows",
icon: "mdi:image-edit-outline",
href: "https://comfy.dimensionlab.net",
datasource: httpStatus("https://comfy.dimensionlab.net"),
datasource: uptimeMonitor(6),
}),
service({
id: "models",
@ -393,7 +429,15 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Local model management",
icon: "mdi:brain",
href: "https://models.dimensionlab.net",
datasource: httpStatus("https://models.dimensionlab.net/api/tags"),
datasource: uptimeMonitor(7),
}),
service({
id: "prompt-registry",
label: "Prompt Registry",
description: "Shared prompts, traces, evals",
icon: "mdi:text-box-search",
href: "https://prompts.dimensionlab.net",
datasource: uptimeMonitor(20),
}),
]),
group("systems", "Systems", [
@ -403,7 +447,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "PostgreSQL database browser",
icon: "simple-icons:adminer",
href: "https://db.dimensionlab.net",
datasource: httpStatus("https://db.dimensionlab.net"),
datasource: uptimeMonitor(17),
}),
service({
id: "assistant",
@ -411,7 +455,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Personal AI agent gateway",
icon: "mdi:robot-outline",
href: "https://assistant.dimensionlab.net",
datasource: httpStatus("https://assistant.dimensionlab.net/health"),
datasource: uptimeMonitor(8),
}),
service({
id: "suna",
@ -419,7 +463,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "AI command center",
icon: "mdi:account-hard-hat-outline",
href: "https://suna.dimensionlab.net",
datasource: httpStatus("https://suna.dimensionlab.net"),
datasource: uptimeMonitor(9),
}),
service({
id: "cockpit-infra",
@ -427,7 +471,23 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "linux-infra server console",
icon: "simple-icons:cockpit",
href: "https://infra-cockpit.dimensionlab.net",
datasource: httpStatus("https://infra-cockpit.dimensionlab.net"),
datasource: uptimeMonitor(15),
}),
service({
id: "cockpit-gpu",
label: "Cockpit GPU",
description: "Linux GPU server console",
icon: "simple-icons:cockpit",
href: "https://linux-cockpit.dimensionlab.net",
datasource: uptimeMonitor(16),
}),
service({
id: "cockpit-network-core",
label: "Cockpit Network Core",
description: "i3 NUC DNS/DHCP console",
icon: "simple-icons:cockpit",
href: "https://network-core.dimensionlab.net",
datasource: uptimeMonitor(19),
}),
]),
group("runtime-health", "Runtime Health", [
@ -437,7 +497,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
description: "Public Git SSH relay",
icon: "simple-icons:forgejo",
href: "https://uptime.dimensionlab.net/status/dimensionlab",
datasource: custom("tcp:git.dimensionlab.net:22"),
datasource: uptimeMonitor(28),
detail: "fallback - uptime monitor pending",
}),
service({
@ -445,7 +505,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
label: "PostgreSQL",
description: "Shared application database",
icon: "simple-icons:postgresql",
datasource: prometheus('pg_up{cluster="dimensionlab"}'),
datasource: uptimeMonitor(22),
detail: "fallback - postgres exporter pending",
}),
service({
@ -453,7 +513,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
label: "Ollama API",
description: "Local model API",
icon: "simple-icons:ollama",
datasource: httpStatus("http://linux-gpu:11434/api/tags"),
datasource: uptimeMonitor(23),
detail: "fallback - internal health check pending",
}),
service({
@ -461,7 +521,31 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
label: "Node Exporter",
description: "Host metrics exporter",
icon: "simple-icons:prometheus",
datasource: prometheus('up{job="node-exporter"}'),
datasource: uptimeMonitor(24),
detail: "fallback - exporter health from Uptime Kuma",
}),
service({
id: "podman-user-exporter",
label: "Podman User Exporter",
description: "Rootless container metrics",
icon: "simple-icons:prometheus",
datasource: uptimeMonitor(25),
detail: "fallback - exporter health from Uptime Kuma",
}),
service({
id: "podman-system-exporter",
label: "Podman System Exporter",
description: "System container metrics",
icon: "simple-icons:prometheus",
datasource: uptimeMonitor(26),
detail: "fallback - exporter health from Uptime Kuma",
}),
service({
id: "network-core-node-exporter",
label: "Network Core Node Exporter",
description: "i3 DNS/DHCP metrics",
icon: "simple-icons:prometheus",
datasource: uptimeMonitor(27),
detail: "fallback - exporter health from Prometheus",
}),
]),
@ -474,6 +558,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
{ id: "last-sync", label: "Last Sync", value: "fallback: 2 minutes ago", severity: "stale" },
{ id: "uptime", label: "Uptime", value: "fallback: 28d 14h 32m", severity: "ok" },
{ id: "load-avg", label: "Load Avg", value: "fallback: 0.47 0.53 0.59", severity: "neutral" },
{ id: "auto-refresh", label: "Auto Refresh", value: "fallback: 15s", severity: "neutral" },
],
},
],
@ -579,17 +664,13 @@ function httpStatus(url: string): DatasourceReference {
};
}
function custom(reference: string): DatasourceReference {
return {
type: "external",
adapter: "custom",
reference,
};
}
function placeholder(reason: string): DatasourceReference {
return {
type: "placeholder",
reason,
};
}
function uptimeMonitor(id: number): DatasourceReference {
return httpStatus(`https://uptime.dimensionlab.net/_homepage-badge/${id}`);
}

View file

@ -0,0 +1,240 @@
import { describe, expect, test, vi } from "vitest";
import {
DASHBOARD_SCHEMA_VERSION,
type DashboardDocument,
} from "$lib/model";
import { resolveDashboardDatasources } from ".";
describe("dashboard datasource resolution", () => {
test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => {
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("https://prometheus.example/api/v1/query_range")) {
return jsonResponse({
status: "success",
data: {
result: [
{
metric: { host: "linux-infra" },
values: [
[1771430000, "10"],
[1771430060, "40"],
[1771430120, "30"],
],
},
{
metric: { host: "linux-gpu" },
values: [
[1771430000, "12"],
[1771430060, "24"],
[1771430120, "36"],
],
},
],
},
});
}
if (url.startsWith("https://prometheus.example/api/v1/query")) {
const query = new URL(url).searchParams.get("query") || "";
if (query.includes("node_boot_time_seconds")) {
return prometheusVector("90061");
}
if (query.includes("node_load15")) return prometheusVector("0.40");
if (query.includes("node_load5")) return prometheusVector("0.46");
if (query.includes("node_load1")) return prometheusVector("0.34");
return jsonResponse({
status: "success",
data: {
result: [
{
metric: { host: "linux-infra", mountpoint: "/home" },
value: [1771430400, "88"],
},
],
},
});
}
if (url.startsWith("https://api.open-meteo.com/v1/forecast")) {
return jsonResponse({
current: {
apparent_temperature: 20.9,
temperature_2m: 21.4,
weather_code: 0,
wind_speed_10m: 12,
},
});
}
if (url === "https://service.example/health") {
return jsonResponse({
status: "UP",
ping: 42,
});
}
throw new Error(`Unhandled test request: ${url}`);
});
const resolved = await resolveDashboardDatasources(testDashboard(), {
fetch,
prometheusBaseUrl: "https://prometheus.example",
});
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/query?"),
expect.objectContaining({ cache: "no-store" }),
);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/query_range?"),
expect.objectContaining({ cache: "no-store" }),
);
const telemetry = resolved.telemetry[0];
expect(telemetry.value).toEqual({ kind: "percent", value: 88 });
expect(telemetry.severity).toBe("warning");
expect(telemetry.detail).toBe("linux-infra /home");
expect(telemetry.sparkline).toEqual([12, 40, 36]);
const service = resolved.serviceGroups[0].services[0];
expect(service.severity).toBe("ok");
expect(service.detail).toBe("42 ms");
const weather = resolved.modules?.find((module) => module.id === "weather-amsterdam");
expect(weather?.value).toBe("21.4 C");
expect(weather?.detail).toBe("Clear - feels 20.9 C - wind 12 km/h");
expect(weather?.severity).toBe("ok");
const summary = resolved.modules?.find((module) => module.id === "runtime-health-summary");
expect(summary?.value).toBe("all systems operational");
expect(summary?.detail).toBe("1 service ok");
expect(summary?.severity).toBe("ok");
expect(resolved.statusStrips[0].items).toEqual([
{ id: "system-status", label: "System Status", value: "All systems operational", severity: "ok" },
{ id: "last-sync", label: "Last Sync", value: "just now", severity: "ok" },
{ id: "uptime", label: "Uptime", value: "1d 1h 1m", severity: "ok" },
{ id: "load-avg", label: "Load Avg", value: "0.34 0.46 0.40", severity: "neutral" },
{ id: "auto-refresh", label: "Auto Refresh", value: "15s", severity: "neutral" },
]);
expect(resolved).not.toBe(testDocument);
expect(testDocument.telemetry[0].value.value).toBe(1);
});
});
const testDocument = testDashboard();
function testDashboard(): DashboardDocument {
return {
schemaVersion: DASHBOARD_SCHEMA_VERSION,
metadata: {
title: "System Overview",
refreshIntervalSeconds: 15,
},
layout: {
telemetry: ["cpu"],
serviceGroups: ["services"],
statusStrips: ["footer"],
modules: ["weather-amsterdam", "runtime-health-summary"],
},
telemetry: [
{
id: "cpu",
label: "CPU",
value: { kind: "percent", value: 1 },
detail: "fallback",
severity: "stale",
thresholds: { warning: 70, danger: 90 },
datasource: {
type: "external",
adapter: "prometheus",
reference: "fixture_cpu_query",
},
sparkline: [1],
},
],
serviceGroups: [
{
id: "services",
title: "Services",
services: [
{
id: "api",
label: "API",
description: "Example API",
severity: "stale",
detail: "fallback",
datasource: {
type: "external",
adapter: "http-status",
reference: "GET https://service.example/health",
},
},
],
},
],
statusStrips: [
{
id: "footer",
items: [
{ id: "system-status", label: "System Status", value: "fallback", severity: "ok" },
{ id: "last-sync", label: "Last Sync", value: "fallback", severity: "stale" },
{ id: "uptime", label: "Uptime", value: "fallback", severity: "ok" },
{ id: "load-avg", label: "Load Avg", value: "fallback", severity: "neutral" },
{ id: "auto-refresh", label: "Auto Refresh", value: "fallback", severity: "neutral" },
],
},
],
modules: [
{
id: "weather-amsterdam",
kind: "weather",
title: "Amsterdam",
value: "fallback",
detail: "fallback",
severity: "stale",
datasource: {
type: "external",
adapter: "weather",
reference: "open-meteo:latitude=52.3676&longitude=4.9041",
},
},
{
id: "runtime-health-summary",
kind: "summary",
title: "Runtime Health",
value: "fallback",
detail: "fallback",
severity: "stale",
datasource: {
type: "placeholder",
reason: "summary pending live health aggregation",
},
},
],
};
}
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
headers: { "content-type": "application/json" },
});
}
function prometheusVector(value: string): Response {
return jsonResponse({
status: "success",
data: {
result: [
{
metric: { host: "linux-infra" },
value: [1771430400, value],
},
],
},
});
}

View file

@ -0,0 +1,675 @@
import type {
DashboardDocument,
DashboardModule,
MetricValue,
ServiceEntry,
ServiceGroup,
Severity,
StatusItem,
StatusStrip,
TelemetryCard,
} from "$lib/model";
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,
};
}
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);
}
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`;
}

View file

@ -50,9 +50,13 @@
<style>
.dashboard-frame {
display: grid;
box-sizing: border-box;
height: 100vh;
min-height: 100vh;
gap: var(--ui-space-3);
padding: clamp(0.75rem, 1.3vw, 1.25rem);
gap: 0.36rem;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
padding: clamp(0.42rem, 0.65vw, 0.62rem);
background:
linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
rgba(0, 0, 0, 0.18);
@ -60,11 +64,12 @@
.dashboard-frame__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ui-space-4);
grid-template-columns: minmax(30rem, 1fr) minmax(30rem, 0.9fr);
gap: 0.5rem;
align-items: start;
border-bottom: var(--ui-border);
padding-bottom: var(--ui-space-3);
min-height: 5.35rem;
padding-bottom: 0.34rem;
}
.dashboard-frame__header p,
@ -85,32 +90,65 @@
max-width: 100%;
overflow-wrap: anywhere;
font-family: var(--ui-font-display);
font-size: clamp(2.45rem, 5vw, 4.2rem);
font-size: clamp(2.35rem, 3.65vw, 3.25rem);
font-weight: 850;
letter-spacing: 0;
line-height: 0.85;
line-height: 0.78;
white-space: nowrap;
}
.dashboard-frame__modules {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: minmax(15rem, 0.66fr) minmax(17rem, 1fr);
justify-content: end;
gap: var(--ui-space-3);
gap: 0.5rem;
}
.dashboard-frame__panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--ui-space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
align-content: start;
gap: 0.36rem;
min-height: 0;
overflow: hidden;
}
.dashboard-frame__panels :global(.service-panel[data-model-id="runtime-health"]) {
grid-column: 1 / -1;
}
.dashboard-frame__panels
:global(.service-panel[data-model-id="runtime-health"] .panel__body) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 780px) {
.dashboard-frame {
height: auto;
min-height: 100vh;
overflow: visible;
}
.dashboard-frame__header {
grid-template-columns: 1fr;
}
.dashboard-frame__modules {
justify-content: stretch;
grid-template-columns: 1fr;
}
h1 {
white-space: normal;
}
.dashboard-frame__panels {
grid-template-columns: 1fr;
overflow: visible;
}
.dashboard-frame__panels
:global(.service-panel[data-model-id="runtime-health"] .panel__body) {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -34,7 +34,7 @@
.footer-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 2.65rem;
min-height: 1.9rem;
align-items: center;
background: rgba(2, 3, 2, 0.92);
color: inherit;
@ -44,7 +44,7 @@
span,
strong {
min-width: 0;
padding: var(--ui-space-2) var(--ui-space-3);
padding: 0.34rem 0.52rem;
overflow-wrap: anywhere;
text-transform: uppercase;
}
@ -53,13 +53,13 @@
height: 100%;
border-right: var(--ui-border);
color: var(--ui-color-muted);
font-size: 0.68rem;
font-size: 0.52rem;
font-weight: 800;
}
strong {
color: var(--ui-color-text);
font-size: 0.76rem;
font-size: 0.58rem;
}
.footer-cell[data-severity="ok"] strong {

View file

@ -27,8 +27,8 @@
<style>
.icon-glyph {
display: inline-grid;
width: 2.35rem;
height: 2.35rem;
width: 1.55rem;
height: 1.55rem;
place-items: center;
border: var(--ui-border);
background: #050605;
@ -37,17 +37,17 @@
}
.icon-glyph :global(svg) {
width: 1.35rem;
height: 1.35rem;
width: 0.95rem;
height: 0.95rem;
}
.icon-glyph[data-size="sm"] {
width: 1.75rem;
height: 1.75rem;
width: 1.1rem;
height: 1.1rem;
}
.icon-glyph[data-size="lg"] {
width: 3rem;
height: 3rem;
width: 2.35rem;
height: 2.35rem;
}
</style>

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import "uplot/dist/uPlot.min.css";
import type { UiSeverity } from "../types";
let {
values = [],
severity = "neutral",
label = "Telemetry trend",
}: {
values?: number[];
severity?: UiSeverity;
label?: string;
} = $props();
let chartElement: HTMLDivElement;
let chart: ChartInstance | null = null;
let resizeObserver: ResizeObserver | null = null;
onMount(async () => {
const module = await import("uplot");
chart = new module.default(chartOptions(chartElement, severity), chartData(values), chartElement);
resizeObserver = new ResizeObserver(() => {
chart?.setSize(chartSize(chartElement));
});
resizeObserver.observe(chartElement);
});
$effect(() => {
chart?.setData(chartData(values));
chart?.setSize(chartSize(chartElement));
});
onDestroy(() => {
resizeObserver?.disconnect();
chart?.destroy();
});
</script>
<div
class="line-chart"
data-chart-library="uplot"
data-severity={severity}
role="img"
aria-label={label}
>
<div class="line-chart__canvas" bind:this={chartElement} aria-hidden="true"></div>
</div>
<style>
.line-chart {
position: relative;
min-width: 0;
height: 1rem;
color: var(--ui-color-accent);
}
.line-chart[data-severity="warning"] {
color: var(--ui-color-warning);
}
.line-chart[data-severity="danger"] {
color: var(--ui-color-danger);
}
.line-chart[data-severity="stale"],
.line-chart[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.line-chart__canvas {
width: 100%;
height: 100%;
}
.line-chart__canvas :global(.uplot) {
width: 100% !important;
height: 100% !important;
background: transparent;
font-family: var(--ui-font-mono);
}
.line-chart__canvas :global(.u-over),
.line-chart__canvas :global(.u-under) {
overflow: visible;
}
</style>
<script lang="ts" module>
type ChartInstance = {
destroy(): void;
setData(data: import("uplot").AlignedData): void;
setSize(size: { width: number; height: number }): void;
};
function chartData(values: number[]): import("uplot").AlignedData {
const normalized = values.length ? values : [0, 0];
return [
normalized.map((_, index) => index),
normalized.map((value) => Math.max(0, Number(value) || 0)),
];
}
function chartOptions(
element: HTMLElement,
severity: import("../types").UiSeverity,
): import("uplot").Options {
return {
...chartSize(element),
cursor: { show: false },
legend: { show: false },
padding: [2, 0, 2, 0],
scales: {
x: { time: false },
y: { auto: true },
},
axes: [
{ show: false },
{ show: false },
],
series: [
{},
{
stroke: chartStroke(element, severity),
width: 2,
points: { show: false },
},
],
};
}
function chartSize(element?: HTMLElement): { width: number; height: number } {
return {
width: Math.max(80, Math.round(element?.clientWidth || 120)),
height: Math.max(20, Math.round(element?.clientHeight || 24)),
};
}
function chartStroke(element: HTMLElement, severity: import("../types").UiSeverity): string {
const styles = window.getComputedStyle(element.closest(".line-chart") || element);
const currentColor = styles.color;
if (currentColor) return currentColor;
if (severity === "danger") return "#ff1744";
if (severity === "warning") return "#ffb020";
return "#d7ff00";
}
</script>

View file

@ -37,12 +37,13 @@
.module-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ui-space-4);
gap: 0.5rem;
align-items: start;
min-width: min(100%, 18rem);
min-width: 0;
min-height: 4.15rem;
border: var(--ui-border);
background: rgba(6, 8, 7, 0.82);
padding: var(--ui-space-3);
padding: 0.5rem;
}
h2,
@ -52,23 +53,29 @@
h2 {
color: var(--ui-color-muted);
font-size: 0.82rem;
font-size: 0.6rem;
line-height: 1;
text-transform: uppercase;
}
strong {
display: block;
margin-top: var(--ui-space-1);
margin-top: 0.15rem;
font-family: var(--ui-font-display);
font-size: 2.25rem;
line-height: 0.9;
font-size: clamp(1.45rem, 2vw, 2rem);
line-height: 0.82;
overflow-wrap: anywhere;
}
p {
margin-top: var(--ui-space-1);
margin-top: 0.2rem;
color: var(--ui-color-muted);
font-size: 0.72rem;
font-size: 0.52rem;
line-height: 1.05;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.module-card[data-severity="ok"] :global(.icon-glyph) {

View file

@ -56,13 +56,13 @@
}
.panel__header {
padding: var(--ui-space-3) var(--ui-space-3) 0;
padding: 0.42rem 0.55rem 0;
}
h2 {
margin: 0;
font-family: var(--ui-font-display);
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-size: clamp(1.45rem, 2.05vw, 1.95rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.9;
@ -71,8 +71,8 @@
.panel__body {
display: grid;
gap: var(--ui-space-2);
padding: var(--ui-space-3);
gap: 0.25rem;
padding: 0.42rem 0.55rem 0.55rem;
}
.panel[data-density="compact"] .panel__body {

View file

@ -42,13 +42,13 @@
.service-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--ui-space-3);
gap: 0.42rem;
align-items: center;
min-height: 3.75rem;
min-height: 2.35rem;
border: var(--ui-border);
background: rgba(12, 13, 12, 0.72);
color: inherit;
padding: var(--ui-space-2);
padding: 0.3rem 0.38rem;
text-decoration: none;
}
@ -79,19 +79,25 @@
}
h3 {
overflow-wrap: anywhere;
font-size: 0.88rem;
overflow: hidden;
font-size: 0.66rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.05;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
p {
margin-top: 0.22rem;
margin-top: 0.16rem;
overflow: hidden;
color: var(--ui-color-muted);
font-size: 0.68rem;
line-height: 1.25;
font-size: 0.5rem;
line-height: 1.08;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
@media (max-width: 580px) {

View file

@ -15,15 +15,16 @@
<style>
.status-badge {
display: inline-grid;
min-height: 1.6rem;
min-height: 1.12rem;
align-items: center;
border: var(--ui-border);
padding: 0 var(--ui-space-2);
padding: 0 0.34rem;
color: var(--ui-color-muted);
font-size: 0.72rem;
font-size: 0.52rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
white-space: nowrap;
}
.status-badge[data-severity="ok"] {

View file

@ -22,13 +22,13 @@
<style>
.status-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
.status-strip[data-compact="true"] {
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
}
</style>

View file

@ -2,6 +2,7 @@
import { formatMetricValue } from "../format";
import type { UiTelemetryCard } from "../types";
import IconGlyph from "./IconGlyph.svelte";
import LineChart from "./LineChart.svelte";
let { card }: { card: UiTelemetryCard } = $props();
@ -23,9 +24,7 @@
</div>
{/if}
{#if card.sparkline?.length}
<svg class="telemetry-card__sparkline" viewBox="0 0 100 24" aria-hidden="true">
<polyline points={sparklinePoints(card.sparkline)} />
</svg>
<LineChart values={card.sparkline} severity={card.severity} label={`${card.label} trend`} />
{/if}
{#if card.detail || card.description}
<p>{card.detail || card.description}</p>
@ -35,12 +34,12 @@
<style>
.telemetry-card {
display: grid;
min-height: 7.15rem;
min-height: 5.15rem;
border: var(--ui-border);
background: linear-gradient(180deg, rgba(13, 15, 14, 0.82), rgba(2, 3, 2, 0.92));
padding: var(--ui-space-3);
padding: 0.45rem 0.55rem 0.42rem;
color: var(--ui-color-text);
gap: var(--ui-space-2);
gap: 0.25rem;
}
.telemetry-card[data-severity="warning"] {
@ -69,7 +68,11 @@
display: flex;
min-width: 0;
align-items: center;
gap: var(--ui-space-2);
gap: 0.35rem;
}
header :global(.icon-glyph) {
display: none;
}
h3,
@ -80,29 +83,34 @@
h3 {
overflow-wrap: anywhere;
color: inherit;
font-size: 0.78rem;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.02;
text-transform: uppercase;
}
strong {
font-family: var(--ui-font-display);
font-size: clamp(2rem, 4vw, 3rem);
font-size: clamp(1.75rem, 2.75vw, 2.4rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.82;
line-height: 0.76;
white-space: nowrap;
}
p {
overflow: hidden;
color: var(--ui-color-muted);
font-size: 0.67rem;
line-height: 1.2;
font-size: 0.53rem;
line-height: 1.05;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.telemetry-card__bar {
height: 0.35rem;
height: 0.25rem;
border: var(--ui-border);
background: #030403;
}
@ -114,19 +122,6 @@
background: currentColor;
}
.telemetry-card__sparkline {
width: 100%;
height: 1.5rem;
color: var(--ui-color-accent);
}
.telemetry-card__sparkline polyline {
fill: none;
stroke: currentColor;
stroke-linecap: square;
stroke-linejoin: miter;
stroke-width: 2;
}
</style>
<script lang="ts" module>
@ -142,17 +137,4 @@
const progress = Number(card.value.value);
return Number.isFinite(progress) ? clampPercent(progress) : null;
}
function sparklinePoints(values: number[]): string {
const max = Math.max(...values, 1);
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
return values
.map((value, index) => {
const x = index * step;
const y = 24 - (Math.max(0, value) / max) * 22;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}
</script>

View file

@ -14,9 +14,21 @@
<style>
.telemetry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(var(--ui-density-card-min), 1fr));
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
@media (max-width: 1100px) {
.telemetry-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.telemetry-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View file

@ -108,6 +108,22 @@ describe("dashboard UI components", () => {
expect(withProgress.body).toContain("--metric-progress: 42%");
});
test("renders telemetry trends through the uPlot chart surface", () => {
const { body } = render(TelemetryCard, {
props: {
card: {
id: "trend-card",
label: "Trend Card",
value: { kind: "percent", value: 64 },
sparkline: [18, 24, 64],
severity: "ok",
},
},
});
expect(body).toContain('data-chart-library="uplot"');
});
test("base button controls forward native attributes", () => {
const button = render(Button, {
props: {

View file

@ -10,6 +10,8 @@ export function formatMetricValue(metric: UiMetricValue): string {
const unit = metric.unit ?? defaultUnit(metric.kind);
if (metric.kind === "bytes") return formatBytes(value, precision);
if (metric.kind === "percent") return `${value.toFixed(precision)}%`;
if (metric.kind === "temperature") return `${value.toFixed(precision)}°C`;
return `${value.toFixed(precision)}${unit ? ` ${unit}` : ""}`;
}

View file

@ -9,6 +9,7 @@ export { default as FooterStatusCell } from "./components/FooterStatusCell.svelt
export { default as GridFrame } from "./components/GridFrame.svelte";
export { default as IconGlyph } from "./components/IconGlyph.svelte";
export { default as IconButton } from "./components/IconButton.svelte";
export { default as LineChart } from "./components/LineChart.svelte";
export { default as ModuleCard } from "./components/ModuleCard.svelte";
export { default as Panel } from "./components/Panel.svelte";
export { default as ProgressMeter } from "./components/ProgressMeter.svelte";

View file

@ -34,6 +34,7 @@ describe("dashboard model renderer", () => {
"footer-status:last-sync",
"footer-status:uptime",
"footer-status:load-avg",
"footer-status:auto-refresh",
]);
expect(dashboard.statusStripId).toBe("footer-status");
});

View file

@ -0,0 +1,13 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import LineChart from "../components/LineChart.svelte";
const { Story } = defineMeta({
title: "Dashboard Primitives/LineChart",
component: LineChart,
});
</script>
<Story name="Neutral Trend" args={{ values: [12, 18, 15, 28, 24, 32] }} />
<Story name="Warning Trend" args={{ values: [22, 34, 48, 62, 74, 70], severity: "warning" }} />
<Story name="Danger Trend" args={{ values: [20, 40, 64, 82, 90, 94], severity: "danger" }} />

View file

@ -1,7 +1,21 @@
import { loadDashboardRuntime } from "$lib/server/dashboard";
import { resolveDashboardDatasources } from "$lib/server/datasources";
export async function load() {
const dashboard = loadDashboardRuntime(undefined, { seedIfEmpty: true });
if (dashboard.state !== "ready") {
return { dashboard };
}
if (process.env.DISABLE_LIVE_DATASOURCES === "1") {
return { dashboard };
}
export function load() {
return {
dashboard: loadDashboardRuntime(undefined, { seedIfEmpty: true }),
dashboard: {
...dashboard,
document: await resolveDashboardDatasources(dashboard.document),
},
};
}

View file

@ -6,6 +6,8 @@ const linkedServiceIds = [
"forgejo",
"wiki",
"aws-start",
"adguard-primary",
"adguard-secondary",
"grafana",
"uptime-kuma",
"prometheus",
@ -14,10 +16,13 @@ const linkedServiceIds = [
"open-webui",
"comfyui",
"models",
"prompt-registry",
"adminer",
"assistant",
"suna",
"cockpit-infra",
"cockpit-gpu",
"cockpit-network-core",
"forgejo-ssh-relay",
];
@ -48,6 +53,32 @@ test.describe("dashboard page QA gate", () => {
});
});
test("fits the operational dashboard into a 1470 by 956 viewport", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop");
await page.setViewportSize({ width: 1470, height: 956 });
await page.goto("/");
const metrics = await page.evaluate(() => {
const footer = document.querySelector("[data-model-id='footer-status']");
const runtime = document.querySelector("[data-model-id='runtime-health']");
return {
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
footerBottom: footer?.getBoundingClientRect().bottom ?? 0,
runtimeBottom: runtime?.getBoundingClientRect().bottom ?? 0,
};
});
expect(metrics.scrollWidth).toBeLessThanOrEqual(1470);
expect(metrics.scrollHeight).toBeLessThanOrEqual(956);
expect(metrics.runtimeBottom).toBeLessThanOrEqual(956);
expect(metrics.footerBottom).toBeLessThanOrEqual(956);
});
test("keeps the first screen usable on mobile", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Before After
Before After