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) => `
+
+
+
+ `,
+ ).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
+
+
+
+
+
+
+
+
+
+
+
System Stats
+
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
+
+
+
+
+
+
+
+
+
+
Storage
+
Root capacity plus disk overview
+
+
+
+
+
+
+ Root Storage
+ Stable
+
+
Primary Volume
+
48.7%
+
+ 233.8 GB USED
+ 246.2 GB FREE
+
+
+
+
+
+
+
+
+ Disk Matrix
+
+
+
Mounted Volumes
+
+
+
+
+
backup
+
/backup
+
+
10%
+
+
+
+
+
+
+
+
+
+
Service Grid
+
Core runtime and monitored services
+
+
+
+
+
+
+ Docker Summary
+ Online
+
+
Container Runtime
+
Secure proxy-backed Docker state for the MVP service layer.
+
+
+
+
+
+
+
+ Uptime Kuma
+ Synced
+
+
Monitor Surface
+
Availability layer for service health, latency and incident signals.
+
+
+
+
+
+
+
+ Home Assistant
+ Reachable
+
+
Core Automation Hub
+
MVP scope limited to online and basis status, no entity rendering yet.
+
+
+
+
+
+
+
+ Immich
+ Degraded
+
+
Photo Pipeline
+
Container active, response times elevated beyond nominal threshold.
+
+
+
+
+
+
+
+ Gitea
+ Healthy
+
+
Git Platform
+
Service healthy across monitor and container state layers.
+
+
+
+
+
+
+
+ AdGuard
+ Offline
+
+
DNS Layer
+
Placeholder service card within MVP grid and future optional adapter set.
+
+
+
+
+
+
+
+
+
Quick Access
+
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