Merge pull request #23 from codex/issue-1-observability-dashboard
feat(dashboard): add live observability overview
This commit is contained in:
commit
769d624799
33 changed files with 1601 additions and 150 deletions
3
bun.lock
3
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
240
src/lib/server/datasources/dashboard-datasources.test.ts
Normal file
240
src/lib/server/datasources/dashboard-datasources.test.ts
Normal 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],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
675
src/lib/server/datasources/index.ts
Normal file
675
src/lib/server/datasources/index.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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-layout="grid"]) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dashboard-frame__panels
|
||||
:global(.service-panel[data-layout="grid"] .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-layout="grid"] .panel__body) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
147
src/lib/ui/components/LineChart.svelte
Normal file
147
src/lib/ui/components/LineChart.svelte
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
let { group }: { group: UiServiceGroup } = $props();
|
||||
</script>
|
||||
|
||||
<div class="service-panel" data-model-id={group.id}>
|
||||
<div class="service-panel" data-layout={group.layout || "list"} data-model-id={group.id}>
|
||||
<Panel title={group.title}>
|
||||
{#if group.summary?.length}
|
||||
<StatusStrip id={`${group.id}:summary`} items={group.summary} compact />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}` : ""}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
13
src/lib/ui/stories/LineChart.stories.svelte
Normal file
13
src/lib/ui/stories/LineChart.stories.svelte
Normal 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" }} />
|
||||
|
|
@ -52,6 +52,7 @@ export interface UiServiceRow {
|
|||
|
||||
export interface UiServiceGroup {
|
||||
id: string;
|
||||
layout?: "grid" | "list";
|
||||
title: string;
|
||||
services: UiServiceRow[];
|
||||
summary?: UiStatusItem[];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import {
|
||||
DashboardFrame,
|
||||
SystemState,
|
||||
dashboardDocumentToUiDashboard,
|
||||
} from "$lib/ui";
|
||||
import { onDestroy } from "svelte";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
|
@ -29,6 +32,30 @@
|
|||
let pageDescription = $derived(
|
||||
dashboard?.subtitle || stateSubtitle || stateMessage,
|
||||
);
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
if (refreshTimer) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
|
||||
const refreshIntervalSeconds =
|
||||
data.dashboard.state === "ready"
|
||||
? data.dashboard.document.metadata.refreshIntervalSeconds
|
||||
: undefined;
|
||||
if (!refreshIntervalSeconds) return;
|
||||
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void invalidateAll();
|
||||
}, refreshIntervalSeconds * 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 238 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 272 KiB |
Loading…
Add table
Add a link
Reference in a new issue