diff --git a/bun.lock b/bun.lock index c120138..db0b7d3 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index fdb14dc..aade3b6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 6ca14cd..2785c33 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/src/lib/model/fixtures/dimensionlab.test.ts b/src/lib/model/fixtures/dimensionlab.test.ts index ae13cbf..38b2ac6 100644 --- a/src/lib/model/fixtures/dimensionlab.test.ts +++ b/src/lib/model/fixtures/dimensionlab.test.ts @@ -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", diff --git a/src/lib/model/fixtures/dimensionlab.ts b/src/lib/model/fixtures/dimensionlab.ts index 9890fef..c2b4808 100644 --- a/src/lib/model/fixtures/dimensionlab.ts +++ b/src/lib/model/fixtures/dimensionlab.ts @@ -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,10 +521,34 @@ 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", }), - ]), + ], "grid"), ], statusStrips: [ { @@ -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" }, ], }, ], @@ -550,12 +635,17 @@ function service(seed: ServiceSeed): ServiceEntry { }; } -function group(id: string, title: string, services: ServiceEntry[]): ServiceGroup { +function group( + id: string, + title: string, + services: ServiceEntry[], + layout: ServiceGroup["layout"] = "list", +): ServiceGroup { return { id, - title, - layout: "list", + layout, services, + title, }; } @@ -579,17 +669,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}`); +} diff --git a/src/lib/server/dashboard.test.ts b/src/lib/server/dashboard.test.ts index 06c45b8..b15fa6c 100644 --- a/src/lib/server/dashboard.test.ts +++ b/src/lib/server/dashboard.test.ts @@ -47,6 +47,68 @@ describe("dashboard runtime loader", () => { expect(store.listRevisions()).toHaveLength(1); }); + test("refreshes an existing initial seed when the bundled seed changes", async () => { + const store = await createTestStore(); + const oldSeed = olderDimensionLabSeed(); + store.seedDashboardIfEmpty(oldSeed, { + actor: "initial-seed", + message: "load initial dashboard document", + }); + + const runtime = loadDashboardRuntime(store, { + refreshSeedDocument: true, + seedDocument: dimensionLabDashboardFixture, + seedIfEmpty: true, + }); + + expect(runtime.state).toBe("ready"); + if (runtime.state !== "ready") throw new Error("expected ready dashboard"); + expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain( + "auto-refresh", + ); + expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain( + "prompt-registry", + ); + expect(store.listRevisions()).toHaveLength(2); + expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed"); + }); + + test("does not refresh a dashboard after a user-authored revision", async () => { + const store = await createTestStore(); + const oldSeed = olderDimensionLabSeed(); + store.seedDashboardIfEmpty(oldSeed, { + actor: "initial-seed", + message: "load initial dashboard document", + }); + store.commitDashboard( + { + ...oldSeed, + metadata: { + ...oldSeed.metadata, + title: "Custom Dashboard", + }, + }, + { + actor: "agent", + message: "customize dashboard", + }, + ); + + const runtime = loadDashboardRuntime(store, { + refreshSeedDocument: true, + seedDocument: dimensionLabDashboardFixture, + seedIfEmpty: true, + }); + + expect(runtime.state).toBe("ready"); + if (runtime.state !== "ready") throw new Error("expected ready dashboard"); + expect(runtime.document.metadata.title).toBe("Custom Dashboard"); + expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).not.toContain( + "auto-refresh", + ); + expect(store.listRevisions()).toHaveLength(2); + }); + test("returns empty state when no dashboard is active and seeding is disabled", async () => { const store = await createTestStore(); @@ -69,3 +131,20 @@ async function createTestStore() { return store; } + +function olderDimensionLabSeed() { + const document = structuredClone(dimensionLabDashboardFixture); + document.statusStrips = document.statusStrips.map((strip) => ({ + ...strip, + items: strip.items.filter((item) => item.id !== "auto-refresh"), + })); + document.serviceGroups = document.serviceGroups.map((group) => + group.id === "ai-automation" + ? { + ...group, + services: group.services.filter((service) => service.id !== "prompt-registry"), + } + : group, + ); + return document; +} diff --git a/src/lib/server/dashboard.ts b/src/lib/server/dashboard.ts index d2bfc39..7821ee0 100644 --- a/src/lib/server/dashboard.ts +++ b/src/lib/server/dashboard.ts @@ -43,6 +43,7 @@ export interface DashboardRuntimeInvalid { } export interface DashboardRuntimeOptions { + refreshSeedDocument?: boolean; seedIfEmpty?: boolean; seedDocument?: DashboardDocument; } @@ -54,8 +55,20 @@ export function loadDashboardRuntime( const dashboardStore = store || createDashboardStore(); try { + const seedDocument = options.seedDocument || dimensionLabDashboardFixture; const active = dashboardStore.getActiveDashboard(); if (active) { + if ( + options.refreshSeedDocument && + shouldRefreshSeedDashboard(active, seedDocument) + ) { + const refreshed = dashboardStore.commitDashboard(seedDocument, { + actor: "initial-seed", + message: "refresh bundled dashboard document", + }); + return readyRuntimeState(refreshed.document, refreshed.id); + } + return readyRuntimeState(active.document, active.currentRevisionId); } @@ -69,7 +82,7 @@ export function loadDashboardRuntime( } const seeded = dashboardStore.seedDashboardIfEmpty( - options.seedDocument || dimensionLabDashboardFixture, + seedDocument, { actor: "initial-seed", message: "load initial dashboard document", @@ -113,3 +126,22 @@ function invalidRuntimeState(errors: string[]): DashboardRuntimeInvalid { errors, }; } + +function shouldRefreshSeedDashboard( + active: { document: DashboardDocument; revision: { actor: string } }, + seedDocument: DashboardDocument, +): boolean { + if (active.revision.actor !== "initial-seed") return false; + if (!isBundledDimensionLabSeed(active.document, seedDocument)) return false; + return JSON.stringify(active.document) !== JSON.stringify(seedDocument); +} + +function isBundledDimensionLabSeed( + document: DashboardDocument, + seedDocument: DashboardDocument, +): boolean { + return ( + document.metadata.title === seedDocument.metadata.title && + document.metadata.description === seedDocument.metadata.description + ); +} diff --git a/src/lib/server/datasources/dashboard-datasources.test.ts b/src/lib/server/datasources/dashboard-datasources.test.ts new file mode 100644 index 0000000..19082a2 --- /dev/null +++ b/src/lib/server/datasources/dashboard-datasources.test.ts @@ -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], + }, + ], + }, + }); +} diff --git a/src/lib/server/datasources/index.ts b/src/lib/server/datasources/index.ts new file mode 100644 index 0000000..804fbb5 --- /dev/null +++ b/src/lib/server/datasources/index.ts @@ -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 { + 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; + +interface PrometheusVectorResult { + metric?: Record; + value?: [number, string]; +} + +interface PrometheusMatrixResult { + metric?: Record; + 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 { + 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 { + return { + ...structuredClone(group), + services: await Promise.all( + group.services.map((service) => resolveService(service, context)), + ), + }; +} + +async function resolveService( + service: ServiceEntry, + context: DatasourceContext, +): Promise { + 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 { + 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 { + 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 { + 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 { + if (module.datasource?.type !== "external" || module.datasource.adapter !== "weather") { + return structuredClone(module); + } + + try { + const weather = await requestJson( + 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 { + 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 { + const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query", { + query, + }); + const payload = await requestJson>( + context.fetch, + url, + context.requestTimeoutMs, + ); + return payload.status === "success" ? payload.data.result || [] : []; +} + +async function prometheusRangeQuery( + query: string, + context: DatasourceContext, +): Promise { + 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>( + context.fetch, + url, + context.requestTimeoutMs, + ); + return payload.status === "success" ? payload.data.result || [] : []; +} + +async function prometheusScalar( + query: string, + context: DatasourceContext, +): Promise { + 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 { + 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( + fetch: DatasourceFetch, + url: string, + timeoutMs: number, +): Promise { + 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; +} + +async function fetchWithTimeout( + fetch: DatasourceFetch, + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + 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 { + 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((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(); + + 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, + 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 { + 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 { + 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`; +} diff --git a/src/lib/ui/components/DashboardFrame.svelte b/src/lib/ui/components/DashboardFrame.svelte index 4a4fff8..5f1f0e4 100644 --- a/src/lib/ui/components/DashboardFrame.svelte +++ b/src/lib/ui/components/DashboardFrame.svelte @@ -50,9 +50,13 @@ diff --git a/src/lib/ui/components/FooterCell.svelte b/src/lib/ui/components/FooterCell.svelte index b71cc66..3093e36 100644 --- a/src/lib/ui/components/FooterCell.svelte +++ b/src/lib/ui/components/FooterCell.svelte @@ -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 { diff --git a/src/lib/ui/components/IconGlyph.svelte b/src/lib/ui/components/IconGlyph.svelte index 81318d6..a231f6d 100644 --- a/src/lib/ui/components/IconGlyph.svelte +++ b/src/lib/ui/components/IconGlyph.svelte @@ -27,8 +27,8 @@ diff --git a/src/lib/ui/components/LineChart.svelte b/src/lib/ui/components/LineChart.svelte new file mode 100644 index 0000000..f8cac24 --- /dev/null +++ b/src/lib/ui/components/LineChart.svelte @@ -0,0 +1,147 @@ + + + + + + + diff --git a/src/lib/ui/components/ModuleCard.svelte b/src/lib/ui/components/ModuleCard.svelte index 2afeedf..852af2d 100644 --- a/src/lib/ui/components/ModuleCard.svelte +++ b/src/lib/ui/components/ModuleCard.svelte @@ -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) { diff --git a/src/lib/ui/components/Panel.svelte b/src/lib/ui/components/Panel.svelte index cbc68e3..776e59d 100644 --- a/src/lib/ui/components/Panel.svelte +++ b/src/lib/ui/components/Panel.svelte @@ -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 { diff --git a/src/lib/ui/components/ServicePanel.svelte b/src/lib/ui/components/ServicePanel.svelte index 0b997d7..864e09a 100644 --- a/src/lib/ui/components/ServicePanel.svelte +++ b/src/lib/ui/components/ServicePanel.svelte @@ -7,7 +7,7 @@ let { group }: { group: UiServiceGroup } = $props(); -
+
{#if group.summary?.length} diff --git a/src/lib/ui/components/ServiceRow.svelte b/src/lib/ui/components/ServiceRow.svelte index 9a8c9b6..91d6028 100644 --- a/src/lib/ui/components/ServiceRow.svelte +++ b/src/lib/ui/components/ServiceRow.svelte @@ -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) { diff --git a/src/lib/ui/components/StatusBadge.svelte b/src/lib/ui/components/StatusBadge.svelte index a0fa058..ba37351 100644 --- a/src/lib/ui/components/StatusBadge.svelte +++ b/src/lib/ui/components/StatusBadge.svelte @@ -15,15 +15,16 @@ diff --git a/src/lib/ui/components/TelemetryCard.svelte b/src/lib/ui/components/TelemetryCard.svelte index f621709..eb16e7e 100644 --- a/src/lib/ui/components/TelemetryCard.svelte +++ b/src/lib/ui/components/TelemetryCard.svelte @@ -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 @@
{/if} {#if card.sparkline?.length} - + {/if} {#if card.detail || card.description}

{card.detail || card.description}

@@ -35,12 +34,12 @@ diff --git a/src/lib/ui/components/TelemetryGrid.svelte b/src/lib/ui/components/TelemetryGrid.svelte index aead537..388d0c9 100644 --- a/src/lib/ui/components/TelemetryGrid.svelte +++ b/src/lib/ui/components/TelemetryGrid.svelte @@ -14,9 +14,21 @@ diff --git a/src/lib/ui/components/render.test.ts b/src/lib/ui/components/render.test.ts index fa55ecb..4a8a145 100644 --- a/src/lib/ui/components/render.test.ts +++ b/src/lib/ui/components/render.test.ts @@ -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: { diff --git a/src/lib/ui/format.ts b/src/lib/ui/format.ts index 4a8a5f1..9a10434 100644 --- a/src/lib/ui/format.ts +++ b/src/lib/ui/format.ts @@ -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}` : ""}`; } diff --git a/src/lib/ui/index.ts b/src/lib/ui/index.ts index 398bcbe..1281a76 100644 --- a/src/lib/ui/index.ts +++ b/src/lib/ui/index.ts @@ -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"; diff --git a/src/lib/ui/model-renderer.test.ts b/src/lib/ui/model-renderer.test.ts index def9d50..62942ee 100644 --- a/src/lib/ui/model-renderer.test.ts +++ b/src/lib/ui/model-renderer.test.ts @@ -25,6 +25,9 @@ describe("dashboard model renderer", () => { expect(dashboard.serviceGroups.map((group) => group.id)).toEqual( dimensionLabDashboardFixture.layout.serviceGroups, ); + expect(dashboard.serviceGroups.find((group) => group.id === "runtime-health")?.layout).toBe( + "grid", + ); expect(dashboard.modules.map((module) => module.id)).toEqual([ "weather-amsterdam", "runtime-health-summary", @@ -34,6 +37,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"); }); diff --git a/src/lib/ui/model-renderer.ts b/src/lib/ui/model-renderer.ts index 96e58f3..d5f3647 100644 --- a/src/lib/ui/model-renderer.ts +++ b/src/lib/ui/model-renderer.ts @@ -58,6 +58,7 @@ function telemetryToUi(card: TelemetryCard): UiTelemetryCard { function serviceGroupToUi(group: ServiceGroup): UiServiceGroup { return { id: group.id, + layout: group.layout, title: group.title, services: group.services.map(serviceToUi), }; diff --git a/src/lib/ui/stories/LineChart.stories.svelte b/src/lib/ui/stories/LineChart.stories.svelte new file mode 100644 index 0000000..ef1ad22 --- /dev/null +++ b/src/lib/ui/stories/LineChart.stories.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/ui/types.ts b/src/lib/ui/types.ts index 55d8aee..792a24b 100644 --- a/src/lib/ui/types.ts +++ b/src/lib/ui/types.ts @@ -52,6 +52,7 @@ export interface UiServiceRow { export interface UiServiceGroup { id: string; + layout?: "grid" | "list"; title: string; services: UiServiceRow[]; summary?: UiStatusItem[]; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index c779342..36e913d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,7 +1,24 @@ import { loadDashboardRuntime } from "$lib/server/dashboard"; +import { resolveDashboardDatasources } from "$lib/server/datasources"; + +export async function load() { + const dashboard = loadDashboardRuntime(undefined, { + refreshSeedDocument: true, + 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), + }, }; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b4c4f11..29e61c9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,12 @@ diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index e7da1ff..c0d65f6 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -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,59 @@ 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']"); + const visibleItems = [ + ...document.querySelectorAll(".telemetry-card"), + ...document.querySelectorAll(".service-row"), + ...document.querySelectorAll(".footer-cell"), + ].map((element) => { + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + height: rect.height, + id: element.getAttribute("data-model-id"), + top: rect.top, + width: rect.width, + }; + }); + + return { + clippedItems: visibleItems.filter((item) => + item.top < 0 || + item.bottom > window.innerHeight || + item.width <= 0 || + item.height <= 0 + ), + footerCellCount: document.querySelectorAll(".footer-cell").length, + scrollWidth: document.documentElement.scrollWidth, + scrollHeight: document.documentElement.scrollHeight, + serviceRowCount: document.querySelectorAll(".service-row").length, + telemetryCardCount: document.querySelectorAll(".telemetry-card").length, + 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); + expect(metrics.telemetryCardCount).toBe(16); + expect(metrics.serviceRowCount).toBe(28); + expect(metrics.footerCellCount).toBe(5); + expect(metrics.clippedItems).toEqual([]); + }); + test("keeps the first screen usable on mobile", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile"); diff --git a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png index 1fdca53..464f6e2 100644 Binary files a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png and b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png differ diff --git a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png index 282fee7..49cc902 100644 Binary files a/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png and b/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png differ