From 89b9173c25a3a01ff058f51aa8782c031782177c Mon Sep 17 00:00:00 2001 From: Micha Date: Sun, 5 Apr 2026 13:43:03 +0200 Subject: [PATCH] Add custom homelab dashboard stack --- apps/dashboard/.dockerignore | 4 + apps/dashboard/.env.example | 21 + apps/dashboard/Dockerfile | 19 + apps/dashboard/assets/js/api.js | 27 + apps/dashboard/assets/js/app.js | 46 + apps/dashboard/assets/js/renderers/header.js | 87 ++ .../assets/js/renderers/quick-access.js | 24 + .../dashboard/assets/js/renderers/services.js | 90 ++ apps/dashboard/assets/js/renderers/stats.js | 65 + apps/dashboard/assets/js/renderers/storage.js | 54 + apps/dashboard/assets/js/state.js | 146 +++ apps/dashboard/backend/app/__init__.py | 1 + .../dashboard/backend/app/clients/__init__.py | 1 + apps/dashboard/backend/app/clients/base.py | 107 ++ .../backend/app/clients/beszel_client.py | 417 ++++++ .../app/clients/docker_proxy_client.py | 103 ++ .../app/clients/home_assistant_client.py | 53 + .../backend/app/clients/uptime_kuma_client.py | 178 +++ apps/dashboard/backend/app/config.py | 66 + apps/dashboard/backend/app/main.py | 79 ++ apps/dashboard/backend/app/models/__init__.py | 1 + apps/dashboard/backend/app/models/common.py | 23 + apps/dashboard/backend/app/models/overview.py | 44 + apps/dashboard/backend/app/models/services.py | 52 + apps/dashboard/backend/app/models/sources.py | 85 ++ apps/dashboard/backend/app/models/storage.py | 27 + apps/dashboard/backend/app/models/system.py | 45 + apps/dashboard/backend/app/routes/__init__.py | 1 + apps/dashboard/backend/app/routes/overview.py | 16 + apps/dashboard/backend/app/routes/services.py | 16 + apps/dashboard/backend/app/routes/storage.py | 16 + apps/dashboard/backend/app/routes/system.py | 16 + .../backend/app/services/__init__.py | 1 + .../backend/app/services/aggregator.py | 383 ++++++ apps/dashboard/backend/app/services/cache.py | 60 + apps/dashboard/backend/requirements.txt | 4 + apps/dashboard/dashboard.html | 1115 +++++++++++++++++ apps/dashboard/docker-compose.yml | 46 + 38 files changed, 3539 insertions(+) create mode 100644 apps/dashboard/.dockerignore create mode 100644 apps/dashboard/.env.example create mode 100644 apps/dashboard/Dockerfile create mode 100644 apps/dashboard/assets/js/api.js create mode 100644 apps/dashboard/assets/js/app.js create mode 100644 apps/dashboard/assets/js/renderers/header.js create mode 100644 apps/dashboard/assets/js/renderers/quick-access.js create mode 100644 apps/dashboard/assets/js/renderers/services.js create mode 100644 apps/dashboard/assets/js/renderers/stats.js create mode 100644 apps/dashboard/assets/js/renderers/storage.js create mode 100644 apps/dashboard/assets/js/state.js create mode 100644 apps/dashboard/backend/app/__init__.py create mode 100644 apps/dashboard/backend/app/clients/__init__.py create mode 100644 apps/dashboard/backend/app/clients/base.py create mode 100644 apps/dashboard/backend/app/clients/beszel_client.py create mode 100644 apps/dashboard/backend/app/clients/docker_proxy_client.py create mode 100644 apps/dashboard/backend/app/clients/home_assistant_client.py create mode 100644 apps/dashboard/backend/app/clients/uptime_kuma_client.py create mode 100644 apps/dashboard/backend/app/config.py create mode 100644 apps/dashboard/backend/app/main.py create mode 100644 apps/dashboard/backend/app/models/__init__.py create mode 100644 apps/dashboard/backend/app/models/common.py create mode 100644 apps/dashboard/backend/app/models/overview.py create mode 100644 apps/dashboard/backend/app/models/services.py create mode 100644 apps/dashboard/backend/app/models/sources.py create mode 100644 apps/dashboard/backend/app/models/storage.py create mode 100644 apps/dashboard/backend/app/models/system.py create mode 100644 apps/dashboard/backend/app/routes/__init__.py create mode 100644 apps/dashboard/backend/app/routes/overview.py create mode 100644 apps/dashboard/backend/app/routes/services.py create mode 100644 apps/dashboard/backend/app/routes/storage.py create mode 100644 apps/dashboard/backend/app/routes/system.py create mode 100644 apps/dashboard/backend/app/services/__init__.py create mode 100644 apps/dashboard/backend/app/services/aggregator.py create mode 100644 apps/dashboard/backend/app/services/cache.py create mode 100644 apps/dashboard/backend/requirements.txt create mode 100644 apps/dashboard/dashboard.html diff --git a/apps/dashboard/.dockerignore b/apps/dashboard/.dockerignore new file mode 100644 index 0000000..5de33ed --- /dev/null +++ b/apps/dashboard/.dockerignore @@ -0,0 +1,4 @@ +backend/.env +backend/app/__pycache__ +backend/app/**/*.pyc +assets/.DS_Store diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example new file mode 100644 index 0000000..171f6f3 --- /dev/null +++ b/apps/dashboard/.env.example @@ -0,0 +1,21 @@ +APP_ENV=production +APP_HOST=0.0.0.0 +APP_PORT=8000 +APP_LOG_LEVEL=INFO +APP_TIMEZONE=Europe/Berlin +APP_NAME=Homelab Dashboard API +APP_VERSION=0.1.0 +CORS_ALLOW_ORIGINS=["https://dashboard.kaleschke.info"] +REQUEST_TIMEOUT_SECONDS=5.0 +CACHE_TTL_OVERVIEW_SECONDS=15 +CACHE_TTL_SYSTEM_SECONDS=15 +CACHE_TTL_SERVICES_SECONDS=15 +CACHE_TTL_STORAGE_SECONDS=30 +BESZEL_BASE_URL=http://beszel:8090 +BESZEL_ADMIN_EMAIL= +BESZEL_ADMIN_PASSWORD= +UPTIME_KUMA_BASE_URL=http://uptime-kuma:3001 +UPTIME_KUMA_USERNAME= +UPTIME_KUMA_PASSWORD= +HOME_ASSISTANT_BASE_URL=http://192.168.178.50:8123 +HOME_ASSISTANT_TOKEN= diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile new file mode 100644 index 0000000..8f7ad43 --- /dev/null +++ b/apps/dashboard/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app/backend + +COPY backend/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/app ./app +WORKDIR /app +COPY dashboard.html ./dashboard.html +COPY assets ./assets + +WORKDIR /app/backend +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/dashboard/assets/js/api.js b/apps/dashboard/assets/js/api.js new file mode 100644 index 0000000..6d6235c --- /dev/null +++ b/apps/dashboard/assets/js/api.js @@ -0,0 +1,27 @@ +const DEFAULT_HEADERS = { + Accept: "application/json", +}; + +async function fetchJson(path) { + const response = await fetch(path, { + headers: DEFAULT_HEADERS, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Request failed for ${path}: ${response.status}`); + } + + return response.json(); +} + +export async function fetchDashboardData() { + const [overview, system, services, storage] = await Promise.all([ + fetchJson("/api/overview"), + fetchJson("/api/system"), + fetchJson("/api/services"), + fetchJson("/api/storage"), + ]); + + return { overview, system, services, storage }; +} diff --git a/apps/dashboard/assets/js/app.js b/apps/dashboard/assets/js/app.js new file mode 100644 index 0000000..1e05381 --- /dev/null +++ b/apps/dashboard/assets/js/app.js @@ -0,0 +1,46 @@ +import { fetchDashboardData } from "./api.js"; +import { getState, setError, subscribe, updateData } from "./state.js"; +import { renderHeader } from "./renderers/header.js"; +import { renderQuickAccess } from "./renderers/quick-access.js"; +import { renderServices } from "./renderers/services.js"; +import { renderStats } from "./renderers/stats.js"; +import { renderStorage } from "./renderers/storage.js"; + +let pollTimer = null; + +function render(state) { + renderHeader(state); + renderStats(state); + renderStorage(state); + renderServices(state); +} + +async function refreshData() { + try { + const payload = await fetchDashboardData(); + updateData(payload); + } catch (error) { + console.error("Dashboard refresh failed", error); + setError(error instanceof Error ? error : new Error("Unknown dashboard refresh error")); + } finally { + restartPolling(); + } +} + +function restartPolling() { + if (pollTimer) { + window.clearInterval(pollTimer); + } + const state = window.__dashboardState?.() ?? null; + const interval = state?.refreshIntervalMs ?? 20000; + pollTimer = window.setInterval(refreshData, interval); +} + +subscribe((state) => { + window.__dashboardState = () => state; + render(state); +}); + +renderQuickAccess(); +refreshData(); +window.setInterval(() => renderHeader(getState()), 1000); diff --git a/apps/dashboard/assets/js/renderers/header.js b/apps/dashboard/assets/js/renderers/header.js new file mode 100644 index 0000000..c347f72 --- /dev/null +++ b/apps/dashboard/assets/js/renderers/header.js @@ -0,0 +1,87 @@ +function formatTime(now) { + return new Intl.DateTimeFormat("de-DE", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(now); +} + +function formatDate(now) { + return new Intl.DateTimeFormat("de-DE", { + weekday: "long", + day: "2-digit", + month: "short", + year: "numeric", + }).format(now).toUpperCase(); +} + +function statusTone(status) { + if (status === "offline") return "offline"; + if (status === "degraded") return "warning"; + return "online"; +} + +function statusLabel(status) { + if (status === "offline") return "OFFLINE"; + if (status === "degraded") return "DEGRADED"; + return "NOMINAL"; +} + +export function renderHeader(state) { + const now = new Date(); + const { overview, services } = state.data; + const refreshNode = document.getElementById("last-refresh"); + const overallTile = document.getElementById("overall-status-tile"); + const overallLabel = document.getElementById("overall-status-label"); + const overallSummary = document.getElementById("overall-status-summary"); + const haTile = document.getElementById("home-assistant-tile"); + const haLabel = document.getElementById("ha-status-label"); + const haSummary = document.getElementById("ha-status-summary"); + const heroCopy = document.getElementById("hero-copy"); + + document.getElementById("clock-time").textContent = formatTime(now); + document.getElementById("clock-date").textContent = formatDate(now); + + if (refreshNode) { + if (state.lastRefreshAt) { + refreshNode.textContent = `LAST REFRESH ${new Intl.DateTimeFormat("de-DE", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(state.lastRefreshAt)} CET`; + } else { + refreshNode.textContent = "LAST REFRESH PENDING"; + } + + refreshNode.classList.toggle("api-error", Boolean(state.error)); + } + + const overallTone = statusTone(overview.overall_status); + overallTile.className = "status-tile"; + overallTile.style.borderColor = ""; + overallLabel.textContent = statusLabel(overview.overall_status); + overallLabel.className = ""; + overallSummary.textContent = + `${overview.services.online} services online, ${overview.services.degraded} degraded, ${overview.services.offline} offline`; + overallTile.classList.toggle("api-error", Boolean(state.error)); + overallLabel.classList.add(`value-${overallTone === "warning" ? "warning" : overallTone === "offline" ? "danger" : "online"}`); + + const haTone = statusTone(overview.home_assistant.status); + haLabel.textContent = overview.home_assistant.status === "online" ? "ONLINE" : "OFFLINE"; + haLabel.className = ""; + haLabel.classList.add(`value-${haTone === "warning" ? "warning" : haTone === "offline" ? "danger" : "online"}`); + haSummary.textContent = + overview.home_assistant.status === "online" + ? `Version ${overview.home_assistant.version ?? "unknown"} / ${overview.home_assistant.response_time_ms ?? "n/a"} ms` + : "Basisstatus derzeit nicht erreichbar"; + haTile.className = "status-tile"; + + if (state.error) { + heroCopy.textContent = `Aggregator API zuletzt fehlerhaft erreichbar. Letzte gueltige Daten bleiben sichtbar. Fehler: ${state.error.message}`; + } else { + heroCopy.textContent = + `Live angebundenes MVP-v1 Dashboard. ${services.summary.docker.running} Container running, ` + + `${services.summary.uptime_kuma.monitors_up} Kuma Monitors up, Home Assistant ${overview.home_assistant.status}.`; + } +} diff --git a/apps/dashboard/assets/js/renderers/quick-access.js b/apps/dashboard/assets/js/renderers/quick-access.js new file mode 100644 index 0000000..263fa67 --- /dev/null +++ b/apps/dashboard/assets/js/renderers/quick-access.js @@ -0,0 +1,24 @@ +const QUICK_LINKS = [ + { section: "Core", title: "Home Assistant", label: "OPEN CONTROL HUB", url: "http://localhost:8123" }, + { section: "Ops", title: "Uptime Kuma", label: "OPEN MONITORS", url: "http://localhost:3001" }, + { section: "Containers", title: "Portainer", label: "OPEN RUNTIME", url: "http://localhost:9000" }, + { section: "Media", title: "Immich", label: "OPEN GALLERY", url: "http://localhost:2283" }, +]; + +export function renderQuickAccess() { + const grid = document.getElementById("quick-grid"); + grid.innerHTML = QUICK_LINKS.map( + (link) => ` +
+
+ ${link.section} +

${link.title}

+ + ${link.label} + > + +
+
+ `, + ).join(""); +} diff --git a/apps/dashboard/assets/js/renderers/services.js b/apps/dashboard/assets/js/renderers/services.js new file mode 100644 index 0000000..a989ede --- /dev/null +++ b/apps/dashboard/assets/js/renderers/services.js @@ -0,0 +1,90 @@ +function serviceTone(status) { + if (status === "offline") return "offline"; + if (status === "degraded") return "warning"; + return "online"; +} + +function healthLabel(status) { + if (status === "offline") return "Offline"; + if (status === "degraded") return "Degraded"; + return "Healthy"; +} + +function sourceLabel(source) { + if (source === "home_assistant") return "Core Automation Hub"; + if (source === "uptime_kuma") return "External availability and latency surface."; + if (source === "docker") return "Container runtime state without external monitor data."; + return "Service state from aggregator."; +} + +function formatTimestamp(value) { + if (!value) return "n/a"; + const date = new Date(value); + return new Intl.DateTimeFormat("de-DE", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(date); +} + +export function renderServices(state) { + const { services, overview } = state.data; + + const dockerPill = document.getElementById("docker-summary-pill"); + dockerPill.className = `status-pill ${services.summary.docker.source_status === "online" ? "online" : "offline"}`; + dockerPill.textContent = services.summary.docker.source_status === "online" ? "Online" : "Offline"; + document.getElementById("docker-running").textContent = String(services.summary.docker.running); + document.getElementById("docker-stopped").textContent = String(services.summary.docker.stopped); + document.getElementById("docker-unhealthy").textContent = String(services.summary.docker.unhealthy); + + const kumaPill = document.getElementById("kuma-summary-pill"); + kumaPill.className = `status-pill ${services.summary.uptime_kuma.source_status === "online" ? "online" : "offline"}`; + kumaPill.textContent = services.summary.uptime_kuma.source_status === "online" ? "Synced" : "Offline"; + document.getElementById("kuma-up").textContent = String(services.summary.uptime_kuma.monitors_up); + document.getElementById("kuma-down").textContent = String(services.summary.uptime_kuma.monitors_down); + document.getElementById("kuma-paused").textContent = String(services.summary.uptime_kuma.monitors_paused); + + const ha = services.services.find((service) => service.id === "homeassistant"); + if (ha) { + const haPill = document.getElementById("service-ha-pill"); + const tone = serviceTone(ha.status); + haPill.className = `status-pill ${tone}`; + haPill.textContent = ha.status === "online" ? "Reachable" : ha.status === "degraded" ? "Degraded" : "Offline"; + document.getElementById("service-ha-version").textContent = overview.home_assistant.version ?? "unknown"; + document.getElementById("service-ha-version").className = "info"; + document.getElementById("service-ha-latency").textContent = ha.latency_ms != null ? `${ha.latency_ms} MS` : "N/A"; + document.getElementById("service-ha-latency").className = tone === "offline" ? "offline" : tone === "warning" ? "warning" : "online"; + document.getElementById("service-ha-last-check").textContent = formatTimestamp(ha.last_checked); + } + + const dynamicServices = services.services.filter((service) => service.id !== "homeassistant").slice(0, 3); + const existingFallbacks = [ + document.getElementById("service-card-fallback-1"), + document.getElementById("service-card-fallback-2"), + document.getElementById("service-card-fallback-3"), + ]; + + dynamicServices.forEach((service, index) => { + const node = existingFallbacks[index]; + if (!node) return; + node.style.display = ""; + const tone = serviceTone(service.status); + const pillClass = tone === "warning" ? "warning" : tone === "offline" ? "offline" : "online"; + node.querySelector(".card-label").textContent = service.name; + node.querySelector(".status-pill").className = `status-pill ${pillClass}`; + node.querySelector(".status-pill").textContent = healthLabel(service.status); + node.querySelector(".card-title").textContent = service.name === "Immich" ? "Photo Pipeline" : service.name === "Gitea" ? "Git Platform" : `${service.name} Service`; + node.querySelector(".card-copy").textContent = sourceLabel(service.source); + + const rows = node.querySelectorAll(".service-meta-row strong"); + rows[0].textContent = service.latency_ms != null ? `${service.latency_ms} MS` : "N/A"; + rows[0].className = tone === "offline" ? "offline" : tone === "warning" ? "warning" : "online"; + rows[1].textContent = String(service.docker_state).toUpperCase(); + rows[1].className = service.docker_state === "stopped" ? "offline" : service.docker_state === "unhealthy" ? "warning" : "online"; + }); + + for (let index = dynamicServices.length; index < existingFallbacks.length; index += 1) { + existingFallbacks[index].style.display = "none"; + } +} diff --git a/apps/dashboard/assets/js/renderers/stats.js b/apps/dashboard/assets/js/renderers/stats.js new file mode 100644 index 0000000..170622e --- /dev/null +++ b/apps/dashboard/assets/js/renderers/stats.js @@ -0,0 +1,65 @@ +function pct(value) { + return `${Math.round(value)}%`; +} + +function gb(value) { + return `${Number(value).toFixed(1)} GB`; +} + +function toneForPercent(value) { + if (value >= 85) return "danger"; + if (value >= 60) return "warning"; + return "online"; +} + +function setSignal(id, tone) { + const node = document.getElementById(id); + node.className = `signal ${tone === "danger" ? "offline" : tone === "warning" ? "warning" : tone === "info" ? "info" : "online"}`; +} + +function setBar(id, fillId, percent, tone) { + const bar = document.getElementById(id); + const fill = document.getElementById(fillId); + bar.className = `metric-bar${tone === "online" ? "" : ` ${tone === "danger" ? "danger" : tone}`}`; + fill.style.width = `${Math.max(0, Math.min(percent, 100))}%`; +} + +export function renderStats(state) { + const { system } = state.data; + const cpuTone = toneForPercent(system.cpu.usage_percent); + const ramTone = toneForPercent(system.memory.usage_percent); + const networkTone = "info"; + const uptimeTone = system.source.status === "online" ? "online" : "danger"; + + setSignal("cpu-signal", cpuTone); + document.getElementById("cpu-value").textContent = pct(system.cpu.usage_percent); + document.getElementById("cpu-value").className = `card-value value-${cpuTone}`; + document.getElementById("cpu-cores").textContent = `${system.cpu.cores} CORES`; + document.getElementById("cpu-cores").className = `metric-accent ${cpuTone === "danger" ? "warning" : "online"}`; + document.getElementById("cpu-load").textContent = system.cpu.load_1.toFixed(2); + setBar("cpu-bar", "cpu-bar-fill", system.cpu.usage_percent, cpuTone); + + setSignal("ram-signal", ramTone); + document.getElementById("ram-value").textContent = pct(system.memory.usage_percent); + document.getElementById("ram-value").className = `card-value value-${ramTone}`; + document.getElementById("ram-used").textContent = gb(system.memory.used_gb); + document.getElementById("ram-used").className = `metric-accent ${ramTone}`; + document.getElementById("ram-free").textContent = gb(system.memory.available_gb); + document.getElementById("ram-free").className = "metric-accent info"; + setBar("ram-bar", "ram-bar-fill", system.memory.usage_percent, ramTone); + + setSignal("network-signal", networkTone); + document.getElementById("network-value").textContent = system.network.rx_mbps.toFixed(1); + document.getElementById("network-value").className = "card-value value-info"; + document.getElementById("network-tx").textContent = `${system.network.tx_mbps.toFixed(1)} MBPS`; + setBar("network-bar", "network-bar-fill", Math.min(system.network.rx_mbps * 4, 100), networkTone); + + const uptimeDays = Math.max(0, Math.floor(system.host.uptime_seconds / 86400)); + const uptimeHours = Math.max(0, Math.floor(system.host.uptime_seconds / 3600)); + setSignal("uptime-signal", uptimeTone); + document.getElementById("uptime-value").textContent = `${uptimeDays}D`; + document.getElementById("uptime-value").className = `card-value value-${uptimeTone === "danger" ? "danger" : "online"}`; + document.getElementById("uptime-host").textContent = system.source.host_name.toUpperCase(); + document.getElementById("uptime-hours").textContent = String(uptimeHours); + setBar("uptime-bar", "uptime-bar-fill", system.source.status === "online" ? 84 : 10, uptimeTone === "danger" ? "danger" : "online"); +} diff --git a/apps/dashboard/assets/js/renderers/storage.js b/apps/dashboard/assets/js/renderers/storage.js new file mode 100644 index 0000000..e65c2a0 --- /dev/null +++ b/apps/dashboard/assets/js/renderers/storage.js @@ -0,0 +1,54 @@ +function gb(value) { + return `${Number(value).toFixed(1)} GB`; +} + +function diskTone(status) { + if (status === "critical" || status === "offline") return "danger"; + if (status === "warning") return "warning"; + return "online"; +} + +function pillLabel(status) { + if (status === "critical") return "Critical"; + if (status === "warning") return "Warning"; + if (status === "offline") return "Offline"; + return "Stable"; +} + +export function renderStorage(state) { + const { storage } = state.data; + const rootTone = diskTone(storage.root.status); + + const rootPill = document.getElementById("root-storage-pill"); + rootPill.className = `status-pill ${rootTone === "danger" ? "offline" : rootTone}`; + rootPill.textContent = pillLabel(storage.root.status); + + document.getElementById("root-storage-value").textContent = `${storage.root.usage_percent.toFixed(1)}%`; + document.getElementById("root-storage-value").className = `card-value value-${rootTone === "danger" ? "danger" : rootTone}`; + document.getElementById("root-storage-used").textContent = gb(storage.root.used_gb); + document.getElementById("root-storage-free").textContent = gb(storage.root.free_gb); + + const rootBar = document.getElementById("root-storage-bar"); + rootBar.className = `metric-bar${rootTone === "online" ? "" : ` ${rootTone === "danger" ? "danger" : rootTone}`}`; + document.getElementById("root-storage-bar-fill").style.width = `${storage.root.usage_percent}%`; + + const signal = document.getElementById("disk-matrix-signal"); + signal.className = `signal ${storage.summary.overall_status === "degraded" ? "warning" : storage.summary.overall_status === "offline" ? "offline" : "online"}`; + + const list = document.getElementById("storage-list"); + list.innerHTML = storage.disks + .map((disk) => { + const tone = diskTone(disk.status); + const pillClass = tone === "danger" ? "offline" : tone === "warning" ? "warning" : "online"; + const barClass = tone === "online" ? "metric-bar" : `metric-bar ${tone === "danger" ? "danger" : tone}`; + return ` +
+ ${disk.name} + ${disk.mount} +
+ ${disk.usage_percent.toFixed(0)}% +
+ `; + }) + .join(""); +} diff --git a/apps/dashboard/assets/js/state.js b/apps/dashboard/assets/js/state.js new file mode 100644 index 0000000..a688877 --- /dev/null +++ b/apps/dashboard/assets/js/state.js @@ -0,0 +1,146 @@ +const DEFAULT_DATA = { + overview: { + generated_at: new Date().toISOString(), + overall_status: "online", + refresh_hint_seconds: 20, + services: { online: 8, degraded: 2, offline: 1, total: 11 }, + docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" }, + system: { + cpu_percent: 23, + ram_percent: 61, + root_storage_percent: 49, + network_rx_mbps: 12.4, + network_tx_mbps: 3.1, + uptime_seconds: 864000, + }, + home_assistant: { + status: "online", + label: "Home Assistant", + version: "2026.3.4", + response_time_ms: 142, + last_checked: new Date().toISOString(), + }, + }, + system: { + generated_at: new Date().toISOString(), + source: { + name: "beszel", + status: "online", + host_name: "homelab-01", + agent_name: "beszel-agent", + }, + cpu: { usage_percent: 23, cores: 8, load_1: 0.82, load_5: 0.74, load_15: 0.69 }, + memory: { used_gb: 19.6, total_gb: 32, available_gb: 12.4, usage_percent: 61 }, + network: { primary_interface: "eth0", rx_mbps: 12.4, tx_mbps: 3.1 }, + host: { uptime_seconds: 864000, platform: "linux", kernel: "6.8.0" }, + }, + services: { + generated_at: new Date().toISOString(), + summary: { + overall_status: "degraded", + docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" }, + uptime_kuma: { monitors_up: 8, monitors_down: 1, monitors_paused: 1, total: 10, source_status: "online" }, + }, + services: [ + { + id: "homeassistant", + name: "Home Assistant", + kind: "core", + status: "online", + health: "healthy", + latency_ms: 142, + docker_state: "running", + url: "#", + source: "home_assistant", + last_checked: new Date().toISOString(), + }, + { + id: "immich", + name: "Immich", + kind: "service", + status: "degraded", + health: "warning", + latency_ms: 821, + docker_state: "running", + url: "#", + source: "uptime_kuma", + last_checked: new Date().toISOString(), + }, + { + id: "gitea", + name: "Gitea", + kind: "service", + status: "online", + health: "healthy", + latency_ms: 98, + docker_state: "running", + url: "#", + source: "uptime_kuma", + last_checked: new Date().toISOString(), + }, + { + id: "adguard", + name: "AdGuard", + kind: "service", + status: "offline", + health: "offline", + latency_ms: null, + docker_state: "stopped", + url: "#", + source: "docker", + last_checked: new Date().toISOString(), + }, + ], + }, + storage: { + generated_at: new Date().toISOString(), + summary: { overall_status: "degraded", critical_disks: 0, warning_disks: 1, total_disks: 3 }, + root: { name: "rootfs", mount: "/", used_gb: 233.8, total_gb: 480, free_gb: 246.2, usage_percent: 48.7, status: "online" }, + disks: [ + { name: "rootfs", mount: "/", used_gb: 233.8, total_gb: 480, free_gb: 246.2, usage_percent: 48.7, status: "online" }, + { name: "data", mount: "/data", used_gb: 712.1, total_gb: 1000, free_gb: 287.9, usage_percent: 71.2, status: "warning" }, + { name: "backup", mount: "/backup", used_gb: 201.4, total_gb: 2000, free_gb: 1798.6, usage_percent: 10.1, status: "online" }, + ], + }, +}; + +const listeners = new Set(); + +const state = { + data: structuredClone(DEFAULT_DATA), + isLoading: true, + error: null, + lastRefreshAt: null, + refreshIntervalMs: 20000, +}; + +export function subscribe(listener) { + listeners.add(listener); + listener(state); + return () => listeners.delete(listener); +} + +export function getState() { + return state; +} + +export function updateData(payload) { + state.data = payload; + state.isLoading = false; + state.error = null; + state.lastRefreshAt = new Date(); + state.refreshIntervalMs = Math.max((payload?.overview?.refresh_hint_seconds ?? 20) * 1000, 15000); + emit(); +} + +export function setError(error) { + state.isLoading = false; + state.error = error; + emit(); +} + +function emit() { + for (const listener of listeners) { + listener(state); + } +} diff --git a/apps/dashboard/backend/app/__init__.py b/apps/dashboard/backend/app/__init__.py new file mode 100644 index 0000000..8285673 --- /dev/null +++ b/apps/dashboard/backend/app/__init__.py @@ -0,0 +1 @@ +"""Homelab dashboard backend package.""" diff --git a/apps/dashboard/backend/app/clients/__init__.py b/apps/dashboard/backend/app/clients/__init__.py new file mode 100644 index 0000000..5ce75ec --- /dev/null +++ b/apps/dashboard/backend/app/clients/__init__.py @@ -0,0 +1 @@ +"""External system clients.""" diff --git a/apps/dashboard/backend/app/clients/base.py b/apps/dashboard/backend/app/clients/base.py new file mode 100644 index 0000000..b925247 --- /dev/null +++ b/apps/dashboard/backend/app/clients/base.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from app.config import Settings + + +logger = logging.getLogger(__name__) + + +class BaseHTTPClient: + def __init__(self, settings: Settings, name: str, base_url: str | None) -> None: + self.settings = settings + self.name = name + self.base_url = str(base_url).rstrip("/") if base_url else None + + async def _request_json( + self, + method: str, + path: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + auth: tuple[str, str] | None = None, + ) -> Any | None: + response = await self._request( + method, + path, + headers=headers, + params=params, + auth=auth, + ) + if response is None: + return None + + try: + return response.json() + except ValueError: + logger.warning("%s returned non-JSON payload for %s", self.name, path) + return None + + async def _request_text( + self, + method: str, + path: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + auth: tuple[str, str] | None = None, + ) -> str | None: + response = await self._request( + method, + path, + headers=headers, + params=params, + auth=auth, + ) + if response is None: + return None + return response.text + + async def _request( + self, + method: str, + path: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + auth: tuple[str, str] | None = None, + ) -> httpx.Response | None: + if not self.base_url: + logger.info("%s client skipped because base URL is not configured", self.name) + return None + + url = f"{self.base_url}/{path.lstrip('/')}" + + try: + async with httpx.AsyncClient( + timeout=self.settings.request_timeout_seconds, + trust_env=False, + ) as client: + response = await client.request( + method, + url, + headers=headers, + params=params, + auth=auth, + ) + response.raise_for_status() + return response + except httpx.TimeoutException: + logger.warning("%s request timed out: %s %s", self.name, method, url) + except httpx.HTTPStatusError as exc: + logger.warning( + "%s request failed with status %s for %s %s", + self.name, + exc.response.status_code, + method, + url, + ) + except httpx.HTTPError as exc: + logger.warning("%s request error for %s %s: %s", self.name, method, url, exc) + + return None diff --git a/apps/dashboard/backend/app/clients/beszel_client.py b/apps/dashboard/backend/app/clients/beszel_client.py new file mode 100644 index 0000000..84fb9cd --- /dev/null +++ b/apps/dashboard/backend/app/clients/beszel_client.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import logging +from typing import Any + +from app.clients.base import BaseHTTPClient +from app.config import Settings +from app.models.sources import ( + BeszelDiskMetric, + BeszelSystemSnapshot, + DockerContainerSummary, + DockerSnapshot, +) + + +logger = logging.getLogger(__name__) + + +class BeszelClient(BaseHTTPClient): + """ + Beszel exposes a PocketBase-backed REST API. The exact record schema may + change between Beszel releases, so this client intentionally normalizes + multiple likely field layouts into a stable internal snapshot. + """ + + def __init__(self, settings: Settings) -> None: + super().__init__(settings, "beszel", settings.beszel_base_url) + self._admin_jwt: str | None = None + self._admin_jwt_expires_at: datetime | None = None + + async def fetch_system_snapshot(self) -> BeszelSystemSnapshot: + snapshot = BeszelSystemSnapshot() + if not self.base_url: + logger.info("beszel skipped: base URL missing") + return snapshot + + headers = await self._build_auth_headers() + if headers is None: + logger.warning("beszel skipped: no usable auth method configured") + return snapshot + + payload = await self._request_json( + "GET", + "/api/collections/system_stats/records", + headers=headers, + params={"page": 1, "perPage": 1, "sort": "-created"}, + ) + if not payload: + logger.warning("beszel returned empty payload") + return snapshot + + logger.info("beszel raw payload: %s", payload) + + items = payload.get("items") if isinstance(payload, dict) else None + if not items: + logger.warning("beszel returned no system_stats records") + return snapshot + + record = items[0] + details = await self._fetch_system_details(headers, record) + normalized = self._normalize_snapshot(record, details) + logger.info("beszel normalized snapshot: %s", normalized.model_dump()) + return normalized + + async def fetch_container_snapshot(self) -> DockerSnapshot: + snapshot = DockerSnapshot() + if not self.base_url: + return snapshot + + headers = await self._build_auth_headers() + if headers is None: + return snapshot + + payload = await self._request_json( + "GET", + "/api/collections/containers/records", + headers=headers, + params={"page": 1, "perPage": 200, "sort": "-updated"}, + ) + logger.info("beszel raw containers payload: %s", payload) + + if not isinstance(payload, dict): + logger.warning("beszel containers mapping: payload is not a dict") + return snapshot + items = payload.get("items") + if not isinstance(items, list): + logger.warning("beszel containers mapping: items missing or not a list") + return snapshot + if not items: + logger.warning("beszel containers mapping: no container records returned") + return snapshot + + containers: list[DockerContainerSummary] = [] + running = 0 + stopped = 0 + unhealthy = 0 + + for item in items: + if not isinstance(item, dict): + continue + state = self._normalize_container_state(item) + if state == "running": + running += 1 + elif state == "unhealthy": + unhealthy += 1 + else: + stopped += 1 + + containers.append( + DockerContainerSummary( + id=str(item.get("id") or ""), + name=str(item.get("name") or item.get("container") or item.get("service") or "unknown"), + state=state, + status_text=str(item.get("status") or item.get("state") or "unknown"), + image=str(item.get("image") or ""), + health=str(item.get("health") or item.get("health_status") or "").lower() or None, + ) + ) + + normalized = DockerSnapshot( + source_status="online", + running=running, + stopped=stopped, + unhealthy=unhealthy, + total=len(containers), + containers=containers, + ) + logger.info("beszel normalized containers snapshot: %s", normalized.model_dump()) + return normalized + + def _normalize_snapshot(self, record: dict[str, Any], details: dict[str, Any] | None) -> BeszelSystemSnapshot: + stats = self._coerce_mapping(record.get("stats") or {}) + details = self._coerce_mapping(details or {}) + details_payload = self._coerce_mapping( + details.get("details") + or details.get("stats") + or details.get("info") + or details.get("data") + or {} + ) + expanded_system = self._coerce_mapping(self._coerce_mapping(record.get("expand")).get("system")) + + memory_used_gb = self._as_float(stats.get("m")) + memory_total_gb = self._as_float(stats.get("m")) + if stats.get("mp"): + memory_total_gb = round(memory_used_gb / (self._as_float(stats.get("mp")) / 100), 1) if self._as_float(stats.get("mp")) else memory_used_gb + memory_available_gb = max(round(memory_total_gb - memory_used_gb, 1), 0.0) + network_pair = stats.get("b") if isinstance(stats.get("b"), list) else [] + disks = self._normalize_disks( + details_payload.get("disks") + or details_payload.get("disk") + or details_payload.get("mounts") + or details.get("disks") + or details.get("disk") + or details.get("mounts") + or [] + ) + if not disks: + logger.info("beszel storage unsupported: no disks/mounts in payload") + + return BeszelSystemSnapshot( + source_status="online", + host_name=str( + details_payload.get("hostname") + or details_payload.get("host") + or details.get("hostname") + or details.get("host") + or expanded_system.get("name") + or record.get("system_name") + or record.get("name") + or "unknown" + ), + agent_name="beszel-agent", + cpu_usage_percent=self._as_float(stats.get("cpu")), + cpu_cores=len(stats.get("cpus")) if isinstance(stats.get("cpus"), list) else 0, + load_1=0.0, + load_5=0.0, + load_15=0.0, + memory_used_gb=memory_used_gb, + memory_total_gb=memory_total_gb, + memory_available_gb=memory_available_gb, + memory_usage_percent=self._as_float(stats.get("mp")), + primary_interface=str( + details_payload.get("primary_interface") + or details_payload.get("interface") + or details.get("primary_interface") + or "primary" + ), + network_rx_mbps=self._network_value_to_mbps(network_pair[0] if len(network_pair) > 0 else 0), + network_tx_mbps=self._network_value_to_mbps(network_pair[1] if len(network_pair) > 1 else 0), + uptime_seconds=self._minutes_to_seconds(stats.get("d")), + platform=str( + details_payload.get("platform") + or details_payload.get("os") + or details.get("platform") + or "unknown" + ), + kernel=str(details_payload.get("kernel") or details.get("kernel") or "unknown"), + disks=disks, + ) + + async def _fetch_system_details( + self, + headers: dict[str, str], + stats_record: dict[str, Any], + ) -> dict[str, Any] | None: + system_id = stats_record.get("system") + params: dict[str, Any] = { + "page": 1, + "perPage": 100, + "sort": "-created", + } + + payload = await self._request_json( + "GET", + "/api/collections/system_details/records", + headers=headers, + params=params, + ) + logger.info("beszel raw details payload: %s", payload) + + if not isinstance(payload, dict): + logger.warning("beszel system_details mapping: payload is not a dict") + return None + items = payload.get("items") + if isinstance(items, list) and items: + if system_id: + for item in items: + if isinstance(item, dict) and str(item.get("system") or "") == str(system_id): + return item + return items[0] + logger.warning("beszel system_details mapping: no matching detail records returned") + return None + + @staticmethod + def _normalize_container_state(item: dict[str, Any]) -> str: + raw = " ".join( + str(item.get(key) or "") + for key in ("status", "state", "health", "health_status") + ).lower() + if "unhealthy" in raw or "degraded" in raw: + return "unhealthy" + if any(token in raw for token in ("running", "up", "healthy", "active")): + return "running" + if raw.strip(): + return "stopped" + return "unknown" + + async def _build_auth_headers(self) -> dict[str, str] | None: + admin_headers = await self._get_admin_auth_headers() + if admin_headers: + return admin_headers + if self.settings.beszel_api_token: + return {"Authorization": self.settings.beszel_api_token} + return None + + async def _get_admin_auth_headers(self) -> dict[str, str] | None: + if not self.settings.beszel_admin_email or not self.settings.beszel_admin_password: + return None + + now = datetime.now(timezone.utc) + if self._admin_jwt and self._admin_jwt_expires_at and now < self._admin_jwt_expires_at: + return {"Authorization": self._admin_jwt} + + auth_payload = await self._request_json( + "POST", + "/api/collections/_superusers/auth-with-password", + headers={"Content-Type": "application/json"}, + params=None, + ) + if auth_payload is None: + auth_payload = await self._request_json_with_body( + "POST", + "/api/collections/_superusers/auth-with-password", + json_body={ + "identity": self.settings.beszel_admin_email, + "password": self.settings.beszel_admin_password, + }, + ) + + if not isinstance(auth_payload, dict): + logger.warning("beszel admin auth failed: no payload") + return None + + token = auth_payload.get("token") + if not token: + logger.warning("beszel admin auth failed: token missing") + return None + + self._admin_jwt = str(token) + self._admin_jwt_expires_at = now + timedelta(minutes=30) + logger.info("beszel admin auth succeeded") + return {"Authorization": self._admin_jwt} + + async def _request_json_with_body( + self, + method: str, + path: str, + *, + json_body: dict[str, Any], + ) -> Any | None: + if not self.base_url: + return None + + import httpx + + url = f"{self.base_url}/{path.lstrip('/')}" + try: + async with httpx.AsyncClient( + timeout=self.settings.request_timeout_seconds, + trust_env=False, + ) as client: + response = await client.request( + method, + url, + json=json_body, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + return response.json() + except httpx.TimeoutException: + logger.warning("beszel auth request timed out: %s %s", method, url) + except httpx.HTTPStatusError as exc: + logger.warning("beszel auth request failed with status %s for %s %s", exc.response.status_code, method, url) + try: + logger.info("beszel auth error payload: %s", exc.response.json()) + except ValueError: + logger.info("beszel auth error text: %s", exc.response.text) + except httpx.HTTPError as exc: + logger.warning("beszel auth request error for %s %s: %s", method, url, exc) + return None + + def _normalize_disks(self, raw_disks: Any) -> list[BeszelDiskMetric]: + if isinstance(raw_disks, dict): + raw_disks = [ + {"mount": key, **(value if isinstance(value, dict) else {"used": value})} + for key, value in raw_disks.items() + ] + disks: list[BeszelDiskMetric] = [] + if not isinstance(raw_disks, list): + return disks + + for item in raw_disks: + if not isinstance(item, dict): + continue + total_gb = self._bytes_to_gb(item.get("total") or item.get("total_bytes")) + used_gb = self._bytes_to_gb(item.get("used") or item.get("used_bytes")) + free_gb = self._bytes_to_gb(item.get("free") or item.get("free_bytes")) + usage_percent = self._as_float(item.get("usage_percent") or item.get("percent")) + if not total_gb and used_gb and free_gb: + total_gb = round(used_gb + free_gb, 1) + + disks.append( + BeszelDiskMetric( + name=str(item.get("name") or item.get("device") or item.get("mount") or "disk"), + mount=str(item.get("mount") or item.get("path") or "/"), + used_gb=used_gb, + total_gb=total_gb, + free_gb=free_gb, + usage_percent=usage_percent, + ) + ) + return disks + + @staticmethod + def _coerce_mapping(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + @staticmethod + def _as_float(value: Any) -> float: + try: + return round(float(value or 0), 1) + except (TypeError, ValueError): + return 0.0 + + @staticmethod + def _as_int(value: Any) -> int: + try: + return int(float(value or 0)) + except (TypeError, ValueError): + return 0 + + @classmethod + def _bytes_to_gb(cls, value: Any) -> float: + if value in (None, ""): + return 0.0 + try: + return round(float(value) / (1024 ** 3), 1) + except (TypeError, ValueError): + return 0.0 + + @classmethod + def _bytes_per_second_to_mbps(cls, value: Any) -> float: + if value in (None, ""): + return 0.0 + try: + return round((float(value) * 8) / 1_000_000, 1) + except (TypeError, ValueError): + return 0.0 + + @classmethod + def _network_value_to_mbps(cls, value: Any) -> float: + try: + numeric = float(value or 0) + except (TypeError, ValueError): + return 0.0 + if numeric <= 0: + return 0.0 + return round((numeric * 8) / 1_000_000, 1) + + @classmethod + def _minutes_to_seconds(cls, value: Any) -> int: + try: + return int(float(value or 0) * 60) + except (TypeError, ValueError): + return 0 diff --git a/apps/dashboard/backend/app/clients/docker_proxy_client.py b/apps/dashboard/backend/app/clients/docker_proxy_client.py new file mode 100644 index 0000000..950992c --- /dev/null +++ b/apps/dashboard/backend/app/clients/docker_proxy_client.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging + +from app.clients.beszel_client import BeszelClient +from app.clients.base import BaseHTTPClient +from app.config import Settings +from app.models.sources import DockerContainerSummary, DockerSnapshot + + +logger = logging.getLogger(__name__) + + +class DockerProxyClient(BaseHTTPClient): + def __init__(self, settings: Settings) -> None: + super().__init__(settings, "docker-proxy", settings.docker_proxy_base_url) + self.beszel_client = BeszelClient(settings) + + async def fetch_containers(self) -> DockerSnapshot: + snapshot = DockerSnapshot() + payload = await self._request_json("GET", "/containers/json", params={"all": "true"}) + if not isinstance(payload, list): + logger.warning("docker proxy returned non-list payload: %s", payload) + fallback = await self.beszel_client.fetch_container_snapshot() + if fallback.source_status == "online": + logger.info("docker proxy fallback to beszel containers succeeded") + return fallback + logger.warning( + "docker integration unavailable: docker proxy unreachable and beszel container fallback returned no usable data" + ) + return snapshot + + logger.info("docker proxy raw payload count: %s", len(payload)) + logger.info("docker proxy raw payload sample: %s", payload[:3]) + + containers: list[DockerContainerSummary] = [] + running = 0 + stopped = 0 + unhealthy = 0 + + for item in payload: + if not isinstance(item, dict): + continue + + state = self._normalize_state(item) + if state == "running": + running += 1 + elif state == "unhealthy": + unhealthy += 1 + else: + stopped += 1 + + containers.append( + DockerContainerSummary( + id=str(item.get("Id") or item.get("ID") or ""), + name=self._normalize_name(item.get("Names")), + state=state, + status_text=str(item.get("Status") or item.get("State") or "unknown"), + image=str(item.get("Image") or ""), + health=self._extract_health(item), + ) + ) + + normalized = DockerSnapshot( + source_status="online", + running=running, + stopped=stopped, + unhealthy=unhealthy, + total=len(containers), + containers=containers, + ) + logger.info("docker proxy normalized snapshot: %s", normalized.model_dump()) + return normalized + + @staticmethod + def _normalize_name(names: object) -> str: + if isinstance(names, list) and names: + return str(names[0]).lstrip("/") + return "unknown" + + @classmethod + def _normalize_state(cls, item: dict) -> str: + status_text = str(item.get("Status") or "").lower() + state = str(item.get("State") or "").lower() + health = cls._extract_health(item) + + if health == "unhealthy" or "unhealthy" in status_text: + return "unhealthy" + if state == "running": + return "running" + if state: + return "stopped" + return "unknown" + + @staticmethod + def _extract_health(item: dict) -> str | None: + if isinstance(item.get("Health"), str): + return str(item["Health"]).lower() + if isinstance(item.get("State"), dict): + health = item["State"].get("Health") + if isinstance(health, dict): + return str(health.get("Status") or "").lower() or None + return None diff --git a/apps/dashboard/backend/app/clients/home_assistant_client.py b/apps/dashboard/backend/app/clients/home_assistant_client.py new file mode 100644 index 0000000..1505e9e --- /dev/null +++ b/apps/dashboard/backend/app/clients/home_assistant_client.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import logging +from time import perf_counter + +from app.clients.base import BaseHTTPClient +from app.config import Settings +from app.models.sources import HomeAssistantSnapshot + + +logger = logging.getLogger(__name__) + + +class HomeAssistantClient(BaseHTTPClient): + def __init__(self, settings: Settings) -> None: + super().__init__(settings, "home-assistant", settings.home_assistant_base_url) + + async def fetch_status(self) -> HomeAssistantSnapshot: + snapshot = HomeAssistantSnapshot() + if not self.base_url or not self.settings.home_assistant_token: + logger.info("home assistant skipped: base URL or token missing") + return snapshot + + headers = { + "Authorization": f"Bearer {self.settings.home_assistant_token}", + "Content-Type": "application/json", + } + + started_at = perf_counter() + api_info = await self._request_json("GET", "/api/", headers=headers) + if api_info is None: + logger.warning("home assistant base API check failed") + return snapshot + + logger.info("home assistant raw /api response: %s", api_info) + + elapsed_ms = int((perf_counter() - started_at) * 1000) + config = await self._request_json("GET", "/api/config", headers=headers) + logger.info("home assistant raw /api/config response: %s", config) + + version = None + if isinstance(config, dict): + version = config.get("version") + + normalized = HomeAssistantSnapshot( + status="online", + version=str(version) if version else None, + response_time_ms=elapsed_ms, + last_checked=datetime.now(timezone.utc), + ) + logger.info("home assistant normalized snapshot: %s", normalized.model_dump()) + return normalized diff --git a/apps/dashboard/backend/app/clients/uptime_kuma_client.py b/apps/dashboard/backend/app/clients/uptime_kuma_client.py new file mode 100644 index 0000000..be0b12a --- /dev/null +++ b/apps/dashboard/backend/app/clients/uptime_kuma_client.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import logging +import re + +import httpx + +from app.clients.base import BaseHTTPClient +from app.config import Settings +from app.models.sources import UptimeKumaMonitor, UptimeKumaSnapshot + + +METRIC_LINE_RE = re.compile(r'^(?P[a-zA-Z_:][a-zA-Z0-9_:]*)\{(?P[^}]*)\}\s+(?P.+)$') +LABEL_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') +logger = logging.getLogger(__name__) + + +class UptimeKumaClient(BaseHTTPClient): + """ + Reads Uptime Kuma monitor status from the documented /metrics endpoint. + This avoids coupling the backend to Socket.IO login flows, but still relies + on Kuma's internal metrics surface, which may change across releases. + """ + + def __init__(self, settings: Settings) -> None: + super().__init__(settings, "uptime-kuma", settings.uptime_kuma_base_url) + + async def fetch_monitors(self) -> UptimeKumaSnapshot: + snapshot = UptimeKumaSnapshot() + if not self.base_url: + logger.info("uptime kuma skipped: base URL missing") + return snapshot + + raw_metrics = None + + if self.settings.uptime_kuma_api_key: + raw_metrics = await self._request_metrics_with_mode( + "basic-api-key", + auth=(self.settings.uptime_kuma_api_key, ""), + ) + + if ( + not raw_metrics + and self.settings.uptime_kuma_username + and self.settings.uptime_kuma_password + ): + raw_metrics = await self._request_metrics_with_mode( + "basic-user", + auth=(self.settings.uptime_kuma_username, self.settings.uptime_kuma_password), + ) + + if raw_metrics is None and not ( + self.settings.uptime_kuma_api_key + or (self.settings.uptime_kuma_username and self.settings.uptime_kuma_password) + ): + logger.info("uptime kuma skipped: no usable metrics auth configured") + return snapshot + + if not raw_metrics: + logger.warning("uptime kuma returned empty metrics payload or metrics auth failed") + return snapshot + + logger.info("uptime kuma raw metrics first 40 lines: %s", raw_metrics.splitlines()[:40]) + + monitors = self._parse_metrics(raw_metrics) + up = sum(1 for monitor in monitors if monitor.status == "online") + down = sum(1 for monitor in monitors if monitor.status == "offline") + paused = sum(1 for monitor in monitors if monitor.status == "degraded") + + normalized = UptimeKumaSnapshot( + source_status="online", + monitors_up=up, + monitors_down=down, + monitors_paused=paused, + total=len(monitors), + monitors=sorted(monitors, key=lambda monitor: monitor.name.lower()), + ) + logger.info("uptime kuma normalized snapshot: %s", normalized.model_dump()) + return normalized + + async def _request_metrics_with_mode( + self, + mode: str, + *, + headers: dict[str, str] | None = None, + auth: tuple[str, str] | None = None, + ) -> str | None: + if not self.base_url: + return None + + url = f"{self.base_url}/metrics" + try: + async with httpx.AsyncClient( + timeout=self.settings.request_timeout_seconds, + trust_env=False, + ) as client: + response = await client.request( + "GET", + url, + headers=headers, + auth=auth, + ) + if response.status_code == 200 and response.text: + logger.info("uptime kuma metrics auth succeeded via %s", mode) + return response.text + + if response.status_code in {401, 403}: + logger.warning("uptime kuma auth failed (401/403)") + else: + logger.info( + "uptime kuma metrics auth failed via %s with status %s", + mode, + response.status_code, + ) + return None + except httpx.TimeoutException: + logger.warning("uptime kuma metrics request timed out via %s", mode) + except httpx.HTTPError as exc: + logger.warning("uptime kuma metrics request error via %s: %s", mode, exc) + return None + + def _parse_metrics(self, payload: str) -> list[UptimeKumaMonitor]: + status_by_id: dict[str, UptimeKumaMonitor] = {} + + for line in payload.splitlines(): + parsed = self._parse_metric_line(line) + if parsed is None: + continue + + metric_name, labels, raw_value = parsed + monitor_id = labels.get("monitor_id") or labels.get("id") or labels.get("monitor") + monitor_name = labels.get("monitor_name") or labels.get("name") + if not monitor_id or not monitor_name: + continue + + monitor = status_by_id.setdefault( + monitor_id, + UptimeKumaMonitor( + id=monitor_id, + name=monitor_name, + status="offline", + monitor_type=labels.get("monitor_type") or labels.get("type"), + ), + ) + + if metric_name == "monitor_status": + status_code = self._as_float(raw_value) + if status_code == 1: + monitor.status = "online" + elif status_code == 3: + monitor.status = "degraded" + else: + monitor.status = "offline" + elif metric_name == "monitor_response_time": + latency = self._as_float(raw_value) + monitor.latency_ms = int(latency) if latency >= 0 else None + + return list(status_by_id.values()) + + @staticmethod + def _parse_metric_line(line: str) -> tuple[str, dict[str, str], str] | None: + if not line or line.startswith("#"): + return None + match = METRIC_LINE_RE.match(line.strip()) + if not match: + return None + labels = { + key: value.encode("utf-8").decode("unicode_escape") + for key, value in LABEL_RE.findall(match.group("labels")) + } + return match.group("name"), labels, match.group("value") + + @staticmethod + def _as_float(value: str) -> float: + try: + return float(value) + except ValueError: + return -1.0 diff --git a/apps/dashboard/backend/app/config.py b/apps/dashboard/backend/app/config.py new file mode 100644 index 0000000..27d3b71 --- /dev/null +++ b/apps/dashboard/backend/app/config.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic import Field, HttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + + +BACKEND_ROOT_DIR = Path(__file__).resolve().parents[1] +ENV_FILE_PATH = BACKEND_ROOT_DIR / ".env" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=ENV_FILE_PATH, + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + app_env: Literal["development", "production", "test"] = "development" + app_host: str = "0.0.0.0" + app_port: int = 8000 + app_log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + app_timezone: str = "Europe/Berlin" + app_name: str = "Homelab Dashboard API" + app_version: str = "0.1.0" + app_root_dir: Path = Path(__file__).resolve().parents[2] + + cors_allow_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"]) + + request_timeout_seconds: float = Field(default=5.0, gt=0) + cache_ttl_overview_seconds: int = Field(default=15, ge=1) + cache_ttl_system_seconds: int = Field(default=15, ge=1) + cache_ttl_services_seconds: int = Field(default=15, ge=1) + cache_ttl_storage_seconds: int = Field(default=30, ge=1) + + beszel_base_url: HttpUrl | None = None + beszel_api_token: str | None = None + beszel_admin_email: str | None = None + beszel_admin_password: str | None = None + + docker_proxy_base_url: HttpUrl | None = None + + uptime_kuma_base_url: HttpUrl | None = None + uptime_kuma_api_key: str | None = None + uptime_kuma_username: str | None = None + uptime_kuma_password: str | None = None + + home_assistant_base_url: HttpUrl | None = None + home_assistant_token: str | None = None + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +def configure_logging(level: str) -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) diff --git a/apps/dashboard/backend/app/main.py b/apps/dashboard/backend/app/main.py new file mode 100644 index 0000000..fce5202 --- /dev/null +++ b/apps/dashboard/backend/app/main.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.config import configure_logging, get_settings +from app.routes.overview import router as overview_router +from app.routes.services import router as services_router +from app.routes.storage import router as storage_router +from app.routes.system import router as system_router + + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + configure_logging(settings.app_log_level) + logger.info("Starting %s v%s in %s mode", settings.app_name, settings.app_version, settings.app_env) + logger.info( + "Config loaded: HOME_ASSISTANT_BASE_URL=%s HOME_ASSISTANT_TOKEN_SET=%s BESZEL_BASE_URL=%s DOCKER_PROXY_BASE_URL=%s UPTIME_KUMA_BASE_URL=%s", + bool(settings.home_assistant_base_url), + bool(settings.home_assistant_token), + bool(settings.beszel_base_url), + bool(settings.docker_proxy_base_url), + bool(settings.uptime_kuma_base_url), + ) + yield + logger.info("Stopping %s", settings.app_name) + + +settings = get_settings() + +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allow_origins, + allow_credentials=True, + allow_methods=["GET"], + allow_headers=["*"], +) + +app.include_router(overview_router) +app.include_router(system_router) +app.include_router(services_router) +app.include_router(storage_router) + +assets_dir = settings.app_root_dir / "assets" +dashboard_file = settings.app_root_dir / "dashboard.html" + +if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + +@app.get("/health", tags=["health"]) +async def health() -> dict[str, str]: + return { + "status": "ok", + "service": settings.app_name, + "version": settings.app_version, + "environment": settings.app_env, + } + + +@app.get("/", include_in_schema=False) +async def dashboard() -> FileResponse: + return FileResponse(dashboard_file) diff --git a/apps/dashboard/backend/app/models/__init__.py b/apps/dashboard/backend/app/models/__init__.py new file mode 100644 index 0000000..565057e --- /dev/null +++ b/apps/dashboard/backend/app/models/__init__.py @@ -0,0 +1 @@ +"""Pydantic models for API and domain data.""" diff --git a/apps/dashboard/backend/app/models/common.py b/apps/dashboard/backend/app/models/common.py new file mode 100644 index 0000000..dadbf4f --- /dev/null +++ b/apps/dashboard/backend/app/models/common.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +SourceStatus = Literal["online", "offline", "unsupported"] +OverallStatus = Literal["online", "degraded", "offline"] +HealthStatus = Literal["healthy", "warning", "offline"] +DiskStatus = Literal["online", "warning", "critical", "offline"] +ServiceKind = Literal["core", "service"] +ServiceSource = Literal["home_assistant", "uptime_kuma", "docker", "manual"] +DockerContainerState = Literal["running", "stopped", "unhealthy", "unknown"] + + +class APIModel(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +class TimestampedResponse(APIModel): + generated_at: datetime diff --git a/apps/dashboard/backend/app/models/overview.py b/apps/dashboard/backend/app/models/overview.py new file mode 100644 index 0000000..9748014 --- /dev/null +++ b/apps/dashboard/backend/app/models/overview.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from app.models.common import APIModel, OverallStatus, SourceStatus, TimestampedResponse + + +class OverviewServicesSummary(APIModel): + online: int + degraded: int + offline: int + total: int + + +class OverviewDockerSummary(APIModel): + running: int + stopped: int + unhealthy: int + total: int + source_status: SourceStatus + + +class OverviewSystemSummary(APIModel): + cpu_percent: float + ram_percent: float + root_storage_percent: float + network_rx_mbps: float + network_tx_mbps: float + uptime_seconds: int + + +class OverviewHomeAssistantSummary(APIModel): + status: SourceStatus + label: str + version: str | None = None + response_time_ms: int | None = None + last_checked: str | None = None + + +class OverviewResponse(TimestampedResponse): + overall_status: OverallStatus + refresh_hint_seconds: int + services: OverviewServicesSummary + docker: OverviewDockerSummary + system: OverviewSystemSummary + home_assistant: OverviewHomeAssistantSummary diff --git a/apps/dashboard/backend/app/models/services.py b/apps/dashboard/backend/app/models/services.py new file mode 100644 index 0000000..d142be1 --- /dev/null +++ b/apps/dashboard/backend/app/models/services.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from app.models.common import ( + APIModel, + DockerContainerState, + HealthStatus, + OverallStatus, + ServiceKind, + ServiceSource, + SourceStatus, + TimestampedResponse, +) + + +class ServicesDockerSummary(APIModel): + running: int + stopped: int + unhealthy: int + total: int + source_status: SourceStatus + + +class ServicesUptimeKumaSummary(APIModel): + monitors_up: int + monitors_down: int + monitors_paused: int + total: int + source_status: SourceStatus + + +class ServicesSummary(APIModel): + overall_status: OverallStatus + docker: ServicesDockerSummary + uptime_kuma: ServicesUptimeKumaSummary + + +class ServiceItem(APIModel): + id: str + name: str + kind: ServiceKind + status: OverallStatus + health: HealthStatus + latency_ms: int | None = None + docker_state: DockerContainerState + url: str | None = None + source: ServiceSource + last_checked: str | None = None + + +class ServicesResponse(TimestampedResponse): + summary: ServicesSummary + services: list[ServiceItem] diff --git a/apps/dashboard/backend/app/models/sources.py b/apps/dashboard/backend/app/models/sources.py new file mode 100644 index 0000000..2afb434 --- /dev/null +++ b/apps/dashboard/backend/app/models/sources.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field + +from app.models.common import APIModel, DockerContainerState, OverallStatus, SourceStatus + + +class BeszelDiskMetric(APIModel): + name: str + mount: str + used_gb: float + total_gb: float + free_gb: float + usage_percent: float + + +class BeszelSystemSnapshot(APIModel): + source_name: str = "beszel" + source_status: SourceStatus = "offline" + host_name: str = "unknown" + agent_name: str = "beszel-agent" + cpu_usage_percent: float = 0.0 + cpu_cores: int = 0 + load_1: float = 0.0 + load_5: float = 0.0 + load_15: float = 0.0 + memory_used_gb: float = 0.0 + memory_total_gb: float = 0.0 + memory_available_gb: float = 0.0 + memory_usage_percent: float = 0.0 + primary_interface: str = "unknown" + network_rx_mbps: float = 0.0 + network_tx_mbps: float = 0.0 + uptime_seconds: int = 0 + platform: str = "unknown" + kernel: str = "unknown" + disks: list[BeszelDiskMetric] = Field(default_factory=list) + + +class DockerContainerSummary(APIModel): + id: str + name: str + state: DockerContainerState + status_text: str + image: str + health: str | None = None + + +class DockerSnapshot(APIModel): + source_name: str = "docker" + source_status: SourceStatus = "offline" + running: int = 0 + stopped: int = 0 + unhealthy: int = 0 + total: int = 0 + containers: list[DockerContainerSummary] = Field(default_factory=list) + + +class UptimeKumaMonitor(APIModel): + id: str + name: str + status: OverallStatus + latency_ms: int | None = None + monitor_type: str | None = None + + +class UptimeKumaSnapshot(APIModel): + source_name: str = "uptime_kuma" + source_status: SourceStatus = "offline" + monitors_up: int = 0 + monitors_down: int = 0 + monitors_paused: int = 0 + total: int = 0 + monitors: list[UptimeKumaMonitor] = Field(default_factory=list) + + +class HomeAssistantSnapshot(APIModel): + source_name: str = "home_assistant" + status: SourceStatus = "offline" + label: str = "Home Assistant" + version: str | None = None + response_time_ms: int | None = None + last_checked: datetime | None = None diff --git a/apps/dashboard/backend/app/models/storage.py b/apps/dashboard/backend/app/models/storage.py new file mode 100644 index 0000000..2c02c1c --- /dev/null +++ b/apps/dashboard/backend/app/models/storage.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from app.models.common import APIModel, DiskStatus, OverallStatus, SourceStatus, TimestampedResponse + + +class StorageSummary(APIModel): + overall_status: OverallStatus + source_status: SourceStatus + critical_disks: int + warning_disks: int + total_disks: int + + +class StorageDisk(APIModel): + name: str + mount: str + used_gb: float + total_gb: float + free_gb: float + usage_percent: float + status: DiskStatus + + +class StorageResponse(TimestampedResponse): + summary: StorageSummary + root: StorageDisk + disks: list[StorageDisk] diff --git a/apps/dashboard/backend/app/models/system.py b/apps/dashboard/backend/app/models/system.py new file mode 100644 index 0000000..f3222d1 --- /dev/null +++ b/apps/dashboard/backend/app/models/system.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from app.models.common import APIModel, SourceStatus, TimestampedResponse + + +class SystemSource(APIModel): + name: str + status: SourceStatus + host_name: str + agent_name: str + + +class SystemCPU(APIModel): + usage_percent: float + cores: int + load_1: float + load_5: float + load_15: float + + +class SystemMemory(APIModel): + used_gb: float + total_gb: float + available_gb: float + usage_percent: float + + +class SystemNetwork(APIModel): + primary_interface: str + rx_mbps: float + tx_mbps: float + + +class SystemHost(APIModel): + uptime_seconds: int + platform: str + kernel: str + + +class SystemResponse(TimestampedResponse): + source: SystemSource + cpu: SystemCPU + memory: SystemMemory + network: SystemNetwork + host: SystemHost diff --git a/apps/dashboard/backend/app/routes/__init__.py b/apps/dashboard/backend/app/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/apps/dashboard/backend/app/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/apps/dashboard/backend/app/routes/overview.py b/apps/dashboard/backend/app/routes/overview.py new file mode 100644 index 0000000..ccf33c5 --- /dev/null +++ b/apps/dashboard/backend/app/routes/overview.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.models.overview import OverviewResponse +from app.services.aggregator import AggregatorService, get_aggregator_service + + +router = APIRouter(prefix="/api", tags=["overview"]) + + +@router.get("/overview", response_model=OverviewResponse) +async def get_overview( + aggregator: AggregatorService = Depends(get_aggregator_service), +) -> OverviewResponse: + return await aggregator.get_overview() diff --git a/apps/dashboard/backend/app/routes/services.py b/apps/dashboard/backend/app/routes/services.py new file mode 100644 index 0000000..b61032b --- /dev/null +++ b/apps/dashboard/backend/app/routes/services.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.models.services import ServicesResponse +from app.services.aggregator import AggregatorService, get_aggregator_service + + +router = APIRouter(prefix="/api", tags=["services"]) + + +@router.get("/services", response_model=ServicesResponse) +async def get_services( + aggregator: AggregatorService = Depends(get_aggregator_service), +) -> ServicesResponse: + return await aggregator.get_services() diff --git a/apps/dashboard/backend/app/routes/storage.py b/apps/dashboard/backend/app/routes/storage.py new file mode 100644 index 0000000..b74fbc4 --- /dev/null +++ b/apps/dashboard/backend/app/routes/storage.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.models.storage import StorageResponse +from app.services.aggregator import AggregatorService, get_aggregator_service + + +router = APIRouter(prefix="/api", tags=["storage"]) + + +@router.get("/storage", response_model=StorageResponse) +async def get_storage( + aggregator: AggregatorService = Depends(get_aggregator_service), +) -> StorageResponse: + return await aggregator.get_storage() diff --git a/apps/dashboard/backend/app/routes/system.py b/apps/dashboard/backend/app/routes/system.py new file mode 100644 index 0000000..519e2eb --- /dev/null +++ b/apps/dashboard/backend/app/routes/system.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.models.system import SystemResponse +from app.services.aggregator import AggregatorService, get_aggregator_service + + +router = APIRouter(prefix="/api", tags=["system"]) + + +@router.get("/system", response_model=SystemResponse) +async def get_system( + aggregator: AggregatorService = Depends(get_aggregator_service), +) -> SystemResponse: + return await aggregator.get_system() diff --git a/apps/dashboard/backend/app/services/__init__.py b/apps/dashboard/backend/app/services/__init__.py new file mode 100644 index 0000000..a69ee20 --- /dev/null +++ b/apps/dashboard/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Application services.""" diff --git a/apps/dashboard/backend/app/services/aggregator.py b/apps/dashboard/backend/app/services/aggregator.py new file mode 100644 index 0000000..bd7be03 --- /dev/null +++ b/apps/dashboard/backend/app/services/aggregator.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from functools import lru_cache +from typing import Iterable + +from app.clients.beszel_client import BeszelClient +from app.clients.docker_proxy_client import DockerProxyClient +from app.clients.home_assistant_client import HomeAssistantClient +from app.clients.uptime_kuma_client import UptimeKumaClient +from app.config import Settings, get_settings +from app.models.common import DiskStatus, HealthStatus, OverallStatus +from app.models.overview import ( + OverviewDockerSummary, + OverviewHomeAssistantSummary, + OverviewResponse, + OverviewServicesSummary, + OverviewSystemSummary, +) +from app.models.services import ( + ServiceItem, + ServicesDockerSummary, + ServicesResponse, + ServicesSummary, + ServicesUptimeKumaSummary, +) +from app.models.sources import ( + BeszelDiskMetric, + BeszelSystemSnapshot, + DockerSnapshot, + HomeAssistantSnapshot, + UptimeKumaMonitor, + UptimeKumaSnapshot, +) +from app.models.storage import StorageDisk, StorageResponse, StorageSummary +from app.models.system import ( + SystemCPU, + SystemHost, + SystemMemory, + SystemNetwork, + SystemResponse, + SystemSource, +) +from app.services.cache import TTLCacheService + + +logger = logging.getLogger(__name__) + + +class AggregatorService: + def __init__( + self, + settings: Settings, + cache: TTLCacheService, + beszel_client: BeszelClient, + docker_client: DockerProxyClient, + uptime_kuma_client: UptimeKumaClient, + home_assistant_client: HomeAssistantClient, + ) -> None: + self.settings = settings + self.cache = cache + self.beszel_client = beszel_client + self.docker_client = docker_client + self.uptime_kuma_client = uptime_kuma_client + self.home_assistant_client = home_assistant_client + + async def get_system(self) -> SystemResponse: + return await self.cache.get_or_load( + "system", + self.settings.cache_ttl_system_seconds, + self._build_system, + ) + + async def get_storage(self) -> StorageResponse: + return await self.cache.get_or_load( + "storage", + self.settings.cache_ttl_storage_seconds, + self._build_storage, + ) + + async def get_services(self) -> ServicesResponse: + return await self.cache.get_or_load( + "services", + self.settings.cache_ttl_services_seconds, + self._build_services, + ) + + async def get_overview(self) -> OverviewResponse: + return await self.cache.get_or_load( + "overview", + self.settings.cache_ttl_overview_seconds, + self._build_overview, + ) + + async def _build_system(self) -> SystemResponse: + snapshot = await self.beszel_client.fetch_system_snapshot() + now = datetime.now(timezone.utc) + return SystemResponse( + generated_at=now, + source=SystemSource( + name=snapshot.source_name, + status=snapshot.source_status, + host_name=snapshot.host_name, + agent_name=snapshot.agent_name, + ), + cpu=SystemCPU( + usage_percent=snapshot.cpu_usage_percent, + cores=snapshot.cpu_cores, + load_1=snapshot.load_1, + load_5=snapshot.load_5, + load_15=snapshot.load_15, + ), + memory=SystemMemory( + used_gb=snapshot.memory_used_gb, + total_gb=snapshot.memory_total_gb, + available_gb=snapshot.memory_available_gb, + usage_percent=snapshot.memory_usage_percent, + ), + network=SystemNetwork( + primary_interface=snapshot.primary_interface, + rx_mbps=snapshot.network_rx_mbps, + tx_mbps=snapshot.network_tx_mbps, + ), + host=SystemHost( + uptime_seconds=snapshot.uptime_seconds, + platform=snapshot.platform, + kernel=snapshot.kernel, + ), + ) + + async def _build_storage(self) -> StorageResponse: + snapshot = await self.beszel_client.fetch_system_snapshot() + now = datetime.now(timezone.utc) + + disks = [self._map_disk(disk, snapshot.source_status) for disk in snapshot.disks] + storage_source_status = snapshot.source_status + if snapshot.source_status == "online" and not disks: + storage_source_status = "unsupported" + if disks: + root = next((disk for disk in disks if disk.mount == "/"), disks[0]) + else: + root = StorageDisk( + name="rootfs", + mount="/", + used_gb=0.0, + total_gb=0.0, + free_gb=0.0, + usage_percent=0.0, + status="offline" if snapshot.source_status == "offline" else "online", + ) + + critical_count = sum(1 for disk in disks if disk.status == "critical") + warning_count = sum(1 for disk in disks if disk.status == "warning") + overall_status = self._combine_statuses(disk.status for disk in disks) if disks else ( + "offline" if snapshot.source_status == "offline" else "online" + ) + + return StorageResponse( + generated_at=now, + summary=StorageSummary( + overall_status=overall_status, + source_status=storage_source_status, + critical_disks=critical_count, + warning_disks=warning_count, + total_disks=len(disks), + ), + root=root, + disks=disks, + ) + + async def _build_services(self) -> ServicesResponse: + docker_snapshot, kuma_snapshot, ha_snapshot = await asyncio.gather( + self.docker_client.fetch_containers(), + self.uptime_kuma_client.fetch_monitors(), + self.home_assistant_client.fetch_status(), + ) + + services = self._merge_services(docker_snapshot, kuma_snapshot, ha_snapshot) + overall_status = self._combine_statuses(service.status for service in services) + + return ServicesResponse( + generated_at=datetime.now(timezone.utc), + summary=ServicesSummary( + overall_status=overall_status, + docker=ServicesDockerSummary( + running=docker_snapshot.running, + stopped=docker_snapshot.stopped, + unhealthy=docker_snapshot.unhealthy, + total=docker_snapshot.total, + source_status=docker_snapshot.source_status, + ), + uptime_kuma=ServicesUptimeKumaSummary( + monitors_up=kuma_snapshot.monitors_up, + monitors_down=kuma_snapshot.monitors_down, + monitors_paused=kuma_snapshot.monitors_paused, + total=kuma_snapshot.total, + source_status=kuma_snapshot.source_status, + ), + ), + services=services, + ) + + async def _build_overview(self) -> OverviewResponse: + system, storage, services, ha_snapshot = await asyncio.gather( + self.get_system(), + self.get_storage(), + self.get_services(), + self.home_assistant_client.fetch_status(), + ) + + ha_service = next((item for item in services.services if item.id == "homeassistant"), None) + online_services = sum(1 for item in services.services if item.status == "online") + degraded_services = sum(1 for item in services.services if item.status == "degraded") + offline_services = sum(1 for item in services.services if item.status == "offline") + + return OverviewResponse( + generated_at=datetime.now(timezone.utc), + overall_status=self._combine_statuses( + [services.summary.overall_status, storage.summary.overall_status] + ), + refresh_hint_seconds=self.settings.cache_ttl_overview_seconds, + services=OverviewServicesSummary( + online=online_services, + degraded=degraded_services, + offline=offline_services, + total=len(services.services), + ), + docker=OverviewDockerSummary( + running=services.summary.docker.running, + stopped=services.summary.docker.stopped, + unhealthy=services.summary.docker.unhealthy, + total=services.summary.docker.total, + source_status=services.summary.docker.source_status, + ), + system=OverviewSystemSummary( + cpu_percent=system.cpu.usage_percent, + ram_percent=system.memory.usage_percent, + root_storage_percent=storage.root.usage_percent, + network_rx_mbps=system.network.rx_mbps, + network_tx_mbps=system.network.tx_mbps, + uptime_seconds=system.host.uptime_seconds, + ), + home_assistant=OverviewHomeAssistantSummary( + status=ha_snapshot.status, + label="Home Assistant", + version=ha_snapshot.version, + response_time_ms=ha_snapshot.response_time_ms if ha_snapshot.response_time_ms is not None else (ha_service.latency_ms if ha_service else None), + last_checked=ha_snapshot.last_checked.isoformat() if ha_snapshot.last_checked else (ha_service.last_checked if ha_service else None), + ), + ) + + def _merge_services( + self, + docker_snapshot: DockerSnapshot, + kuma_snapshot: UptimeKumaSnapshot, + ha_snapshot: HomeAssistantSnapshot, + ) -> list[ServiceItem]: + docker_by_name = { + self._normalize_identifier(container.name): container for container in docker_snapshot.containers + } + kuma_by_name = { + self._normalize_identifier(monitor.name): monitor for monitor in kuma_snapshot.monitors + } + + services: list[ServiceItem] = [] + merged_names = sorted(set(docker_by_name) | set(kuma_by_name)) + for normalized_name in merged_names: + container = docker_by_name.get(normalized_name) + monitor = kuma_by_name.get(normalized_name) + status = self._resolve_service_status(container.state if container else "unknown", monitor) + services.append( + ServiceItem( + id=normalized_name, + name=monitor.name if monitor else container.name, + kind="service", + status=status, + health=self._status_to_health(status), + latency_ms=monitor.latency_ms if monitor else None, + docker_state=container.state if container else "unknown", + url=None, + source="uptime_kuma" if monitor else "docker", + last_checked=datetime.now(timezone.utc).isoformat(), + ) + ) + + ha_status: OverallStatus = "online" if ha_snapshot.status == "online" else "offline" + services.insert( + 0, + ServiceItem( + id="homeassistant", + name=ha_snapshot.label, + kind="core", + status=ha_status, + health=self._status_to_health(ha_status), + latency_ms=ha_snapshot.response_time_ms, + docker_state=docker_by_name.get("homeassistant").state if docker_by_name.get("homeassistant") else "unknown", + url=str(self.settings.home_assistant_base_url) if self.settings.home_assistant_base_url else None, + source="home_assistant", + last_checked=ha_snapshot.last_checked.isoformat() if ha_snapshot.last_checked else None, + ), + ) + return services + + @staticmethod + def _normalize_identifier(value: str) -> str: + return "".join(ch.lower() for ch in value if ch.isalnum()) + + def _resolve_service_status( + self, + docker_state: str, + monitor: UptimeKumaMonitor | None, + ) -> OverallStatus: + if monitor: + if monitor.status == "offline": + return "offline" + if monitor.status == "degraded": + return "degraded" + + if docker_state == "unhealthy": + return "degraded" + if docker_state == "stopped": + return "offline" + if docker_state == "running": + return "online" + return "offline" if monitor is None else monitor.status + + @staticmethod + def _status_to_health(status: OverallStatus) -> HealthStatus: + if status == "online": + return "healthy" + if status == "degraded": + return "warning" + return "offline" + + def _map_disk(self, disk: BeszelDiskMetric, source_status: str) -> StorageDisk: + if source_status == "offline": + status: DiskStatus = "offline" + elif disk.usage_percent >= 90: + status = "critical" + elif disk.usage_percent >= 75: + status = "warning" + else: + status = "online" + + return StorageDisk( + name=disk.name, + mount=disk.mount, + used_gb=disk.used_gb, + total_gb=disk.total_gb, + free_gb=disk.free_gb, + usage_percent=disk.usage_percent, + status=status, + ) + + def _combine_statuses(self, statuses: Iterable[str]) -> OverallStatus: + normalized = list(statuses) + if not normalized: + return "offline" + if any(status == "offline" for status in normalized): + return "degraded" if any(status == "online" for status in normalized) else "offline" + if any(status in {"degraded", "warning", "critical"} for status in normalized): + return "degraded" + return "online" + +@lru_cache(maxsize=1) +def get_cache_service() -> TTLCacheService: + return TTLCacheService() + + +@lru_cache(maxsize=1) +def get_aggregator_service() -> AggregatorService: + settings = get_settings() + cache = get_cache_service() + return AggregatorService( + settings=settings, + cache=cache, + beszel_client=BeszelClient(settings), + docker_client=DockerProxyClient(settings), + uptime_kuma_client=UptimeKumaClient(settings), + home_assistant_client=HomeAssistantClient(settings), + ) diff --git a/apps/dashboard/backend/app/services/cache.py b/apps/dashboard/backend/app/services/cache.py new file mode 100644 index 0000000..f2d5c3e --- /dev/null +++ b/apps/dashboard/backend/app/services/cache.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Awaitable, Callable, Generic, TypeVar + + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +@dataclass +class CacheEntry(Generic[T]): + value: T + expires_at: datetime + updated_at: datetime + + +class TTLCacheService: + def __init__(self) -> None: + self._entries: dict[str, CacheEntry[object]] = {} + self._locks: dict[str, asyncio.Lock] = {} + + async def get_or_load( + self, + key: str, + ttl_seconds: int, + loader: Callable[[], Awaitable[T]], + ) -> T: + entry = self._entries.get(key) + if entry and not self._is_expired(entry): + return entry.value # type: ignore[return-value] + + lock = self._locks.setdefault(key, asyncio.Lock()) + async with lock: + entry = self._entries.get(key) + if entry and not self._is_expired(entry): + return entry.value # type: ignore[return-value] + + try: + value = await loader() + now = datetime.now(timezone.utc) + self._entries[key] = CacheEntry( + value=value, + expires_at=now + timedelta(seconds=ttl_seconds), + updated_at=now, + ) + return value + except Exception as exc: + if entry: + logger.warning("Cache loader failed for %s, serving stale data: %s", key, exc) + return entry.value # type: ignore[return-value] + raise + + @staticmethod + def _is_expired(entry: CacheEntry[object]) -> bool: + return datetime.now(timezone.utc) >= entry.expires_at diff --git a/apps/dashboard/backend/requirements.txt b/apps/dashboard/backend/requirements.txt new file mode 100644 index 0000000..410a24c --- /dev/null +++ b/apps/dashboard/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pydantic-settings==2.10.1 +httpx==0.28.1 diff --git a/apps/dashboard/dashboard.html b/apps/dashboard/dashboard.html new file mode 100644 index 0000000..a10390e --- /dev/null +++ b/apps/dashboard/dashboard.html @@ -0,0 +1,1115 @@ + + + + + + KalliLab Control Panel + + + + + + +
+
+ +
+
+
+
+
+ Homelab Command Surface +

KALLILAB
CONTROL PANEL

+

+ Eigenstaendiges MVP-v1 Frontend-Mockup mit voller visueller Praesenz: + atmosphaerischer Dark-Tech-Look, neon teal Highlights, technische Typografie, + Glass Panels und dichte Control-Surface-Stimmung. +

+
+ MVP-V1 SCOPE LOCKED + FASTAPI AGGREGATOR READY + VANILLA JS FRONTEND +
+
+ +
+
+ Overall Cluster State + NOMINAL + 8 Services online, 2 degraded, 1 offline +
+
+ Home Assistant + ONLINE + Basisstatus aktiv, API-Mapping fuer MVP vorbereitet +
+
+
+
+ + +
+ +
+
+ +

Beszel-backed host telemetry

+
+ +
+
+
+
+ CPU + +
+

Compute Load

+
23%
+
+ 8 CORES ACTIVE + L1 0.82 +
+
+
+
+ +
+
+
+ RAM + +
+

Memory Pressure

+
61%
+
+ 19.6 GB USED + 12.4 GB FREE +
+
+
+
+ +
+
+
+ Network + +
+

Traffic Flow

+
12.4
+
+ RX MBPS + TX 3.1 MBPS +
+
+
+
+ +
+
+
+ Uptime + +
+

Host Runtime

+
10D
+
+ NODE HOMELAB-01 + 240 HRS +
+
+
+
+
+
+ +
+
+ +

Root capacity plus disk overview

+
+ +
+
+
+
+ Root Storage + Stable +
+

Primary Volume

+
48.7%
+
+ 233.8 GB USED + 246.2 GB FREE +
+
+
+
+ +
+
+
+ Disk Matrix + +
+

Mounted Volumes

+
+
+ rootfs + / +
+ 49% +
+
+ data + /data +
+ 71% +
+
+ backup + /backup +
+ 10% +
+
+
+
+
+
+ +
+
+ +

Core runtime and monitored services

+
+ +
+
+
+
+ Docker Summary + Online +
+

Container Runtime

+

Secure proxy-backed Docker state for the MVP service layer.

+
+
Running18
+
Stopped2
+
Unhealthy1
+
+
+
+ +
+
+
+ Uptime Kuma + Synced +
+

Monitor Surface

+

Availability layer for service health, latency and incident signals.

+
+
Monitors Up8
+
Monitors Down1
+
Paused1
+
+
+
+ +
+
+
+ Home Assistant + Reachable +
+

Core Automation Hub

+

MVP scope limited to online and basis status, no entity rendering yet.

+
+
Version2026.3.4
+
Latency142 MS
+
Last Check11:59:58
+
+
+
+ +
+
+
+ Immich + Degraded +
+

Photo Pipeline

+

Container active, response times elevated beyond nominal threshold.

+
+
Latency821 MS
+
DockerRUNNING
+
+
+
+ +
+
+
+ Gitea + Healthy +
+

Git Platform

+

Service healthy across monitor and container state layers.

+
+
Latency98 MS
+
DockerRUNNING
+
+
+
+ +
+
+
+ AdGuard + Offline +
+

DNS Layer

+

Placeholder service card within MVP grid and future optional adapter set.

+
+
LatencyN/A
+
DockerSTOPPED
+
+
+
+
+
+ +
+
+ +

Supplemental launch surface only

+
+ +
+ + + + + + + +
+
+ + +
+ + + + diff --git a/apps/dashboard/docker-compose.yml b/apps/dashboard/docker-compose.yml index e69de29..33bb2a3 100644 --- a/apps/dashboard/docker-compose.yml +++ b/apps/dashboard/docker-compose.yml @@ -0,0 +1,46 @@ +services: + dashboard: + build: + context: . + dockerfile: Dockerfile + container_name: kallilab-dashboard + restart: unless-stopped + environment: + APP_ENV: production + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + APP_LOG_LEVEL: INFO + APP_TIMEZONE: Europe/Berlin + APP_NAME: Homelab Dashboard API + APP_VERSION: 0.1.0 + CORS_ALLOW_ORIGINS: '["https://dashboard.kaleschke.info"]' + REQUEST_TIMEOUT_SECONDS: 5.0 + CACHE_TTL_OVERVIEW_SECONDS: 15 + CACHE_TTL_SYSTEM_SECONDS: 15 + CACHE_TTL_SERVICES_SECONDS: 15 + CACHE_TTL_STORAGE_SECONDS: 30 + BESZEL_BASE_URL: http://beszel:8090 + BESZEL_ADMIN_EMAIL: ${BESZEL_ADMIN_EMAIL} + BESZEL_ADMIN_PASSWORD: ${BESZEL_ADMIN_PASSWORD} + UPTIME_KUMA_BASE_URL: http://uptime-kuma:3001 + UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME} + UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD} + HOME_ASSISTANT_BASE_URL: ${HOME_ASSISTANT_BASE_URL} + HOME_ASSISTANT_TOKEN: ${HOME_ASSISTANT_TOKEN} + networks: + - frontend_net + security_opt: + - no-new-privileges:true + labels: + - traefik.enable=true + - traefik.docker.network=frontend_net + - traefik.http.routers.dashboard.rule=Host(`dashboard.kaleschke.info`) + - traefik.http.routers.dashboard.entrypoints=websecure + - traefik.http.routers.dashboard.tls=true + - traefik.http.routers.dashboard.tls.certresolver=le + - traefik.http.routers.dashboard.middlewares=authelia@file,secure-headers@file + - traefik.http.services.dashboard.loadbalancer.server.port=8000 + +networks: + frontend_net: + external: true