diff --git a/HOMELAB_ARCHITECTURE_MASTER_V2.md b/HOMELAB_ARCHITECTURE_MASTER_V2.md
index 8c4bba4..45bb86d 100644
--- a/HOMELAB_ARCHITECTURE_MASTER_V2.md
+++ b/HOMELAB_ARCHITECTURE_MASTER_V2.md
@@ -136,7 +136,7 @@ Diese Dienste sind über echte `*.kaleschke.info`-Domains erreichbar:
- `ntfy` — ntfy.kaleschke.info
- `gitea` (Web) — git.kaleschke.info
- `immich_server` — immich.kaleschke.info
-- `homepage` — homepage.kaleschke.info
+- `homepage` — home.kaleschke.info
### 4.2 Nicht öffentlich / nur Tailscale oder Traefik + Middleware
Diese Dienste sind **keine Public Apps**:
@@ -224,7 +224,7 @@ Legende Status:
| `ddns-updater` | ✅ | `frontend_net` | intern | Cloudflare DNS API; bleibt in `frontend_net` | Dokumentierte Ausnahme |
| `tailscale` | ✅ | `host` | VPN-Zugang | Git-Stack (`host-services/tailscale/`) | `TS_USERSPACE`/`privileged` später prüfen |
| `backrest` | ✅ | `frontend_net`, `backend_net` | Traefik + Middleware | `traefik.docker.network=frontend_net` korrigiert | Breite Mounts straffen (Block F) |
-| `homepage` | ✅ | `frontend_net` | Traefik | öffentliche Startseite via `homepage.kaleschke.info` | — |
+| `homepage` | ✅ | `frontend_net` | Traefik | öffentliche Startseite via `home.kaleschke.info` | — |
### 7.2 Sicherheit / Identity
diff --git a/README.md b/README.md
index 087a9b7..daf24f5 100644
--- a/README.md
+++ b/README.md
@@ -53,4 +53,5 @@ Vor jeder Aenderung lesen:
- Komodo ist der primaere und einzige produktive Stack-Manager.
- Portainer CE ist abgeschaltet und kein Teil des aktiven Betriebs mehr.
+- Homepage ist das aktive produktive Frontend / Start-Dashboard.
- Der verbindliche Detailablauf steht in `docs/WORKFLOW.md`.
diff --git a/apps/dashboard/.dockerignore b/apps/dashboard/.dockerignore
deleted file mode 100644
index 5de33ed..0000000
--- a/apps/dashboard/.dockerignore
+++ /dev/null
@@ -1,4 +0,0 @@
-backend/.env
-backend/app/__pycache__
-backend/app/**/*.pyc
-assets/.DS_Store
diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example
deleted file mode 100644
index b66a190..0000000
--- a/apps/dashboard/.env.example
+++ /dev/null
@@ -1,20 +0,0 @@
-DASHBOARD_IMAGE=
-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
-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
deleted file mode 100644
index 8f7ad43..0000000
--- a/apps/dashboard/Dockerfile
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index 146fbd6..0000000
--- a/apps/dashboard/assets/js/api.js
+++ /dev/null
@@ -1,22 +0,0 @@
-async function fetchJson(path) {
- const res = await fetch(path);
- if (!res.ok) throw new Error(path + " HTTP " + res.status);
- return res.json();
-}
-
-export async function fetchDashboardData() {
- const [overview, system, services, storage, adguard, scrutiny, immich, backrest, home_assistant, uptime_kuma] =
- await Promise.all([
- fetchJson("/api/overview"),
- fetchJson("/api/system"),
- fetchJson("/api/services"),
- fetchJson("/api/storage"),
- fetchJson("/api/adguard"),
- fetchJson("/api/scrutiny"),
- fetchJson("/api/immich"),
- fetchJson("/api/backrest"),
- fetchJson("/api/home_assistant"),
- fetchJson("/api/uptime_kuma"),
- ]);
- return { overview, system, services, storage, adguard, scrutiny, immich, backrest, home_assistant, uptime_kuma };
-}
\ No newline at end of file
diff --git a/apps/dashboard/assets/js/app.js b/apps/dashboard/assets/js/app.js
deleted file mode 100644
index 16407ea..0000000
--- a/apps/dashboard/assets/js/app.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { fetchDashboardData } from "./api.js";
-import { getState, subscribe, updateData } from "./state.js";
-import { renderHeader } from "./renderers/header.js";
-import { renderStats } from "./renderers/stats.js";
-import { renderStorage } from "./renderers/storage.js";
-import { renderServices } from "./renderers/services.js";
-import { renderNetworkHealth } from "./renderers/network-health.js";
-import { renderQuickAccess } from "./renderers/quick-access.js";
-import { renderHomeAssistant } from "./renderers/home-assistant.js";
-import { renderUptimeKuma } from "./renderers/uptime-kuma.js";
-import { renderImmich } from "./renderers/immich.js";
-import { renderBackrest } from "./renderers/backrest.js";
-
-function render(state) {
- renderHeader(state);
- renderStats(state);
- renderStorage(state);
- renderServices(state);
- renderNetworkHealth(state);
- renderHomeAssistant(state);
- renderUptimeKuma(state);
- renderImmich(state);
- renderBackrest(state);
- renderQuickAccess();
-}
-
-subscribe(render);
-
-async function refresh() {
- try {
- const data = await fetchDashboardData();
- updateData(data);
- } catch (err) {
- console.error("Dashboard fetch error:", err);
- }
-}
-
-render(getState());
-refresh();
-setInterval(refresh, (getState().overview?.refresh_hint_seconds ?? 20) * 1000);
\ No newline at end of file
diff --git a/apps/dashboard/assets/js/renderers/backrest.js b/apps/dashboard/assets/js/renderers/backrest.js
deleted file mode 100644
index 62ba0c1..0000000
--- a/apps/dashboard/assets/js/renderers/backrest.js
+++ /dev/null
@@ -1,50 +0,0 @@
-export function renderBackrest(state) {
- const d = state.backrest || {};
- const online = d.source_status === "online";
-
- const pill = document.getElementById("backrest-pill");
- if (pill) {
- pill.textContent = online ? "ONLINE" : "OFFLINE";
- pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
- }
-
- const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
-
- if (online) {
- set("backrest-repos", d.repo_count ?? 0);
- set("backrest-last", fmtAge(d.last_backup_age_hours));
- set("backrest-errors", d.error_count ?? 0);
- } else {
- set("backrest-repos", "—");
- set("backrest-last", "—");
- set("backrest-errors", "—");
- }
-
- // Color last backup age warn if > 26h
- const lastEl = document.getElementById("backrest-last");
- if (lastEl && online) {
- const age = d.last_backup_age_hours;
- lastEl.style.color = age !== null && age > 26 ? "var(--clr-warn)" : "";
- }
-
- // Color errors warn if any
- const errEl = document.getElementById("backrest-errors");
- if (errEl && online) {
- errEl.style.color = (d.error_count ?? 0) > 0 ? "var(--clr-warn)" : "";
- }
-
- // Status dot
- const dot = document.getElementById("backrest-status-dot");
- if (dot) {
- const s = d.last_backup_status || "unknown";
- dot.className = "status-dot dot-" + (s === "ok" ? "ok" : s === "error" ? "err" : "unk");
- dot.title = s;
- }
-}
-
-function fmtAge(hours) {
- if (hours === null || hours === undefined) return "—";
- if (hours < 1) return `${Math.round(hours * 60)}m ago`;
- if (hours < 24) return `${Math.round(hours)}h ago`;
- return `${Math.round(hours / 24)}d ago`;
-}
diff --git a/apps/dashboard/assets/js/renderers/header.js b/apps/dashboard/assets/js/renderers/header.js
deleted file mode 100644
index 6fa0979..0000000
--- a/apps/dashboard/assets/js/renderers/header.js
+++ /dev/null
@@ -1,20 +0,0 @@
-export function renderHeader(state) {
- const overview = state.overview || {};
- const status = overview.overall_status || "offline";
-
- const dot = document.getElementById("overall-dot");
- if (dot) {
- dot.style.background = status === "online" ? "var(--teal)" : status === "degraded" ? "var(--yellow)" : "var(--red)";
- dot.style.boxShadow = status === "online" ? "0 0 8px var(--teal-glow)" : "";
- }
-
- const txt = document.getElementById("overall-status-text");
- if (txt) txt.textContent = status.toUpperCase();
-
- const upd = document.getElementById("last-updated");
- if (upd && overview.generated_at) {
- const d = new Date(overview.generated_at);
- const pad = n => String(n).padStart(2, "0");
- upd.textContent = `updated ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
- }
-}
diff --git a/apps/dashboard/assets/js/renderers/home-assistant.js b/apps/dashboard/assets/js/renderers/home-assistant.js
deleted file mode 100644
index b80e37e..0000000
--- a/apps/dashboard/assets/js/renderers/home-assistant.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export function renderHomeAssistant(state) {
- const d = state.home_assistant || {};
- const online = d.status === "online";
-
- // pill
- const pill = document.getElementById("ha-pill");
- if (pill) {
- pill.textContent = online ? "ONLINE" : "OFFLINE";
- pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
- }
-
- // stat blocks
- const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
-
- set("ha-lights", online ? `${d.lights_on ?? 0}/${d.lights_total ?? 0}` : "—");
- set("ha-climate", online ? (d.climate_active ?? 0) : "—");
- set("ha-doors", online ? (d.doors_open ?? 0) : "—");
- set("ha-alerts", online ? (d.alerts ?? 0) : "—");
-
- // version subtitle
- const ver = document.getElementById("ha-version");
- if (ver) ver.textContent = d.version ? `v${d.version}` : "";
-
- // alerts highlight
- const alertsEl = document.getElementById("ha-alerts");
- if (alertsEl) {
- alertsEl.style.color = d.alerts > 0 ? "var(--clr-warn)" : "";
- }
-}
diff --git a/apps/dashboard/assets/js/renderers/immich.js b/apps/dashboard/assets/js/renderers/immich.js
deleted file mode 100644
index d4120b1..0000000
--- a/apps/dashboard/assets/js/renderers/immich.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export function renderImmich(state) {
- const d = state.immich || {};
- const online = d.source_status === "online";
-
- const pill = document.getElementById("immich-pill");
- if (pill) {
- pill.textContent = online ? "ONLINE" : "OFFLINE";
- pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
- }
-
- const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
-
- if (online) {
- set("immich-photos", fmtNum(d.photos ?? 0));
- set("immich-videos", fmtNum(d.videos ?? 0));
- set("immich-storage", `${(d.storage_gb ?? 0).toFixed(1)} GB`);
- } else {
- set("immich-photos", "—");
- set("immich-videos", "—");
- set("immich-storage", "—");
- }
-}
-
-function fmtNum(n) {
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
- if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
- return String(n);
-}
diff --git a/apps/dashboard/assets/js/renderers/network-health.js b/apps/dashboard/assets/js/renderers/network-health.js
deleted file mode 100644
index 4c17602..0000000
--- a/apps/dashboard/assets/js/renderers/network-health.js
+++ /dev/null
@@ -1,69 +0,0 @@
-export function renderNetworkHealth(state) {
- renderAdGuard(state.adguard || {});
- renderScrutiny(state.scrutiny || {});
-}
-
-function renderAdGuard(data) {
- const online = data.source_status === "online";
- setPill("adguard-pill", online ? "ONLINE" : "OFFLINE", online ? "pill-online" : "pill-offline");
-
- setText("adguard-total", online ? fmtCompact(data.total_queries) : "\u2014");
- setText("adguard-blocked", online ? fmtCompact(data.blocked_queries) : "\u2014");
- setText("adguard-blocked-pct", online ? `${Math.round(data.blocked_percent ?? 0)}%` : "\u2014");
- setText("adguard-latency", online ? `${Math.round(data.avg_processing_ms ?? 0)}ms` : "\u2014");
-
- const fill = document.getElementById("adguard-bar-fill");
- if (fill) {
- fill.style.width = `${online ? Math.min(data.blocked_percent ?? 0, 100) : 0}%`;
- }
-}
-
-function renderScrutiny(data) {
- const online = data.source_status === "online";
- setPill("scrutiny-pill", online ? "ONLINE" : "OFFLINE", online ? "pill-online" : "pill-offline");
-
- const total = data.total_count ?? 0;
- const failed = data.failed_count ?? 0;
- const passed = Math.max(total - failed, 0);
-
- setText("scrutiny-total", online ? total : "\u2014");
- setText("scrutiny-passed", online ? passed : "\u2014");
- setText("scrutiny-failed", online ? failed : "\u2014");
-
- const list = document.getElementById("scrutiny-list");
- if (!list) return;
-
- const devices = Array.isArray(data.devices) ? data.devices.slice(0, 3) : [];
- if (!online || devices.length === 0) {
- list.innerHTML = `
\u2014 ${online ? "no disks" : "offline"}
`;
- return;
- }
-
- list.innerHTML = `${devices.map((device) => {
- const status = device.status || "unknown";
- const cls = status === "passed" ? "ok" : status === "failed" ? "fail" : "unk";
- const token = status === "passed" ? "OK" : status === "failed" ? "ER" : "--";
- const name = device.name || device.device || "disk";
-
- return `${token}${name}`;
- }).join("")}
`;
-}
-
-function setText(id, value) {
- const el = document.getElementById(id);
- if (el) el.textContent = value;
-}
-
-function setPill(id, label, cls) {
- const el = document.getElementById(id);
- if (!el) return;
- el.textContent = label;
- el.className = `status-pill ${cls}`;
-}
-
-function fmtCompact(value) {
- const num = Number(value ?? 0);
- if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
- if (num >= 1_000) return `${Math.round(num / 1_000)}K`;
- return `${num}`;
-}
diff --git a/apps/dashboard/assets/js/renderers/quick-access.js b/apps/dashboard/assets/js/renderers/quick-access.js
deleted file mode 100644
index b505f9a..0000000
--- a/apps/dashboard/assets/js/renderers/quick-access.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const QUICK_LINKS = [
- { label: "Home Assistant", icon: "🏠", url: "https://ha.kaleschke.info" },
- { label: "Komodo", icon: "🦎", url: "https://komodo.kaleschke.info" },
- { label: "Uptime Kuma", icon: "📡", url: "https://uptime.kaleschke.info" },
- { label: "Paperless", icon: "📄", url: "https://paperless.kaleschke.info" },
- { label: "Mealie", icon: "🍽️", url: "https://mealie.kaleschke.info" },
- { label: "Immich", icon: "🖼️", url: "https://immich.kaleschke.info" },
- { label: "Gitea", icon: "🐙", url: "https://git.kaleschke.info" },
- { label: "Code Server", icon: "💻", url: "https://code.kaleschke.info" },
- { label: "FileBrowser", icon: "📁", url: "https://files.kaleschke.info" },
- { label: "Backrest", icon: "💾", url: "https://backrest.kaleschke.info" },
- { label: "Vaultwarden", icon: "🔐", url: "https://vault.kaleschke.info" },
- { label: "AdGuard", icon: "🛡️", url: "https://adguard.kaleschke.info" },
- { label: "Traefik", icon: "🔀", url: "https://traefik.kaleschke.info" },
- { label: "Scrutiny", icon: "🔍", url: "https://scrutiny.kaleschke.info" },
-];
-
-export function renderQuickAccess() {
- const grid = document.getElementById("quick-access-grid");
- if (!grid) return;
- grid.innerHTML = QUICK_LINKS.map(({ label, icon, url }) => `
-
- ${icon}
- ${label}
-
- `).join("");
-}
diff --git a/apps/dashboard/assets/js/renderers/services.js b/apps/dashboard/assets/js/renderers/services.js
deleted file mode 100644
index b316191..0000000
--- a/apps/dashboard/assets/js/renderers/services.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export function renderServices(state) {
- const services = state.services || {};
- const summary = services.summary || {};
-
- const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
-
- set("svc-online", summary.online ?? "—");
- set("svc-degraded", summary.degraded ?? "—");
- set("svc-offline", summary.offline ?? "—");
- set("svc-total", summary.total ?? "—");
-
- const pill = document.getElementById("services-pill");
- if (pill) {
- const s = summary.overall_status || "offline";
- pill.textContent = s.toUpperCase();
- pill.className = "status-pill " + (s === "online" ? "pill-online" : s === "degraded" ? "pill-degraded" : "pill-offline");
- }
-
- // Colour counts
- const degEl = document.getElementById("svc-degraded");
- if (degEl) degEl.className = "stat-num" + ((summary.degraded ?? 0) > 0 ? " warn" : "");
- const offEl = document.getElementById("svc-offline");
- if (offEl) offEl.className = "stat-num" + ((summary.offline ?? 0) > 0 ? " danger" : "");
-}
diff --git a/apps/dashboard/assets/js/renderers/stats.js b/apps/dashboard/assets/js/renderers/stats.js
deleted file mode 100644
index 5e42252..0000000
--- a/apps/dashboard/assets/js/renderers/stats.js
+++ /dev/null
@@ -1,49 +0,0 @@
-export function renderStats(state) {
- const sys = state.system || {};
- const overview = state.overview || {};
- const cpu = sys.cpu || {};
- const mem = sys.memory || {};
- const net = sys.network || {};
- const host = sys.host || {};
- const docker = overview.docker || state.services?.docker || {};
-
- const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
-
- // CPU
- set("cpu-percent", cpu.usage_percent != null ? `${cpu.usage_percent.toFixed(1)}%` : "—");
- set("cpu-cores", cpu.cores ?? "—");
- set("cpu-load", cpu.load_5 != null ? cpu.load_5.toFixed(2) : "—");
-
- // Colour CPU
- const cpuEl = document.getElementById("cpu-percent");
- if (cpuEl && cpu.usage_percent != null) {
- cpuEl.className = "stat-num" + (cpu.usage_percent > 85 ? " danger" : cpu.usage_percent > 65 ? " warn" : "");
- }
-
- // Memory
- set("ram-percent", mem.usage_percent != null ? `${mem.usage_percent.toFixed(1)}%` : "—");
- set("ram-used", mem.used_gb != null ? mem.used_gb.toFixed(1) : "—");
- set("ram-total", mem.total_gb != null ? mem.total_gb.toFixed(0) : "—");
-
- const ramEl = document.getElementById("ram-percent");
- if (ramEl && mem.usage_percent != null) {
- ramEl.className = "stat-num" + (mem.usage_percent > 85 ? " danger" : mem.usage_percent > 65 ? " warn" : "");
- }
-
- // Network
- set("net-rx", net.rx_mbps != null ? net.rx_mbps.toFixed(1) : "—");
- set("net-tx", net.tx_mbps != null ? net.tx_mbps.toFixed(1) : "—");
-
- // Host
- const upDays = host.uptime_seconds != null ? Math.floor(host.uptime_seconds / 86400) : null;
- set("uptime-days", upDays != null ? upDays : "—");
- set("host-platform", host.platform ? host.platform.slice(0, 5).toUpperCase() : "—");
-
- // Docker
- set("docker-running", docker.running ?? "—");
- set("docker-stopped", docker.stopped ?? "—");
- set("docker-total", docker.total ?? "—");
-
- const stoppedEl = document.getElementById("docker-stopped");
- if (stoppedEl) stoppedEl.className = "stat-num" + ((docker.stopped ?? 0) > 0 ? " warn" : " dim");
-}
diff --git a/apps/dashboard/assets/js/renderers/storage.js b/apps/dashboard/assets/js/renderers/storage.js
deleted file mode 100644
index ee77a51..0000000
--- a/apps/dashboard/assets/js/renderers/storage.js
+++ /dev/null
@@ -1,86 +0,0 @@
-export function renderStorage(state) {
- const storage = state.storage || {};
- const grid = document.getElementById("storage-grid");
- if (!grid) return;
-
- const root = storage.root || storage.disks?.[0] || null;
- const disks = storage.disks || [];
- const rootPct = root?.usage_percent ?? 0;
- const rootTone = pickTone(rootPct);
- const warningCount = disks.filter((disk) => between(disk.usage_percent, 70, 85)).length;
- const criticalCount = disks.filter((disk) => (disk.usage_percent ?? 0) > 85).length;
- const highest = disks.length
- ? disks.reduce((current, disk) => ((disk.usage_percent ?? 0) > (current.usage_percent ?? 0) ? disk : current), disks[0])
- : null;
- const matrixPill = criticalCount > 0 ? "pill-offline" : warningCount > 0 ? "pill-degraded" : "pill-online";
- const strip = disks.length
- ? disks.slice(0, 4).map((disk) => {
- const pct = disk.usage_percent ?? 0;
- return `${disk.name || disk.mount} ${pct.toFixed(0)}%`;
- }).join("")
- : 'No disk data';
-
- grid.innerHTML = `
-
-
-
-
- ROOT STORAGE
-
-
${(root?.status || "stable").toUpperCase()}
-
-
-
${root ? `${rootPct.toFixed(1)}%` : "\u2014"}
Usage
-
${root ? fmtNum(root.used_gb) : "\u2014"}
Used GB
-
${root ? fmtNum(root.free_gb) : "\u2014"}
Free GB
-
-
-
${root?.mount || "/"}${root ? `${rootPct.toFixed(1)}%` : "0%"}
-
-
-
-
-
-
-
- DISK MATRIX
-
-
${disks.length}
-
-
-
${disks.length || "\u2014"}
Volumes
-
-
-
${highest ? `${(highest.usage_percent ?? 0).toFixed(0)}%` : "\u2014"}
Peak
-
-
-
- `;
-}
-
-function fmtNum(value) {
- return value == null ? "\u2014" : Number(value).toFixed(1);
-}
-
-function between(value, min, max) {
- const num = value ?? 0;
- return num > min && num <= max;
-}
-
-function pickTone(pct) {
- if (pct > 85) return "danger";
- if (pct > 70) return "warn";
- return "";
-}
-
-function pickColor(pct) {
- if (pct > 85) return "var(--red)";
- if (pct > 70) return "var(--yellow)";
- return "var(--teal-bright)";
-}
-
-function statusPill(status) {
- if (status === "critical") return "pill-offline";
- if (status === "warning") return "pill-degraded";
- return "pill-online";
-}
diff --git a/apps/dashboard/assets/js/renderers/uptime-kuma.js b/apps/dashboard/assets/js/renderers/uptime-kuma.js
deleted file mode 100644
index aaee541..0000000
--- a/apps/dashboard/assets/js/renderers/uptime-kuma.js
+++ /dev/null
@@ -1,53 +0,0 @@
-export function renderUptimeKuma(state) {
- const d = state.uptime_kuma || {};
- const online = d.source_status === "online";
-
- const pill = document.getElementById("uk-pill");
- if (pill) {
- pill.textContent = online ? "ONLINE" : "OFFLINE";
- pill.className = "status-pill " + (online ? "pill-online" : "pill-offline");
- }
-
- const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- set("uk-up", online ? (d.monitors_up ?? 0) : "—");
- set("uk-down", online ? (d.monitors_down ?? 0) : "—");
- const total = (d.monitors_up ?? 0) + (d.monitors_down ?? 0);
- const pct = total > 0 ? Math.round((d.monitors_up / total) * 100) : (online ? 100 : 0);
- set("uk-uptime", online ? `${pct}%` : "—");
-
- // down monitors list
- const downList = document.getElementById("uk-down-list");
- if (downList) {
- const downs = (d.monitors || []).filter(m => m.status === "offline");
- if (!online || downs.length === 0) {
- downList.innerHTML = "";
- } else {
- downList.innerHTML = downs.map(m =>
- `▼ ${m.name}`
- ).join("");
- }
- }
-
- // uptime bars per monitor (top 6 by name)
- const barsContainer = document.getElementById("uk-bars");
- if (barsContainer) {
- const monitors = (d.monitors || []).slice(0, 6);
- if (!online || monitors.length === 0) {
- barsContainer.innerHTML = '—';
- } else {
- barsContainer.innerHTML = monitors.map(m => {
- const beats = m.heartbeats && m.heartbeats.length
- ? m.heartbeats
- : (m.status === "online" ? Array(20).fill(1) : Array(20).fill(0));
- const segments = beats.slice(-20).map(b =>
- ``
- ).join("");
- return `
-
- ${m.name}
- ${segments}
-
`;
- }).join("");
- }
- }
-}
diff --git a/apps/dashboard/assets/js/state.js b/apps/dashboard/assets/js/state.js
deleted file mode 100644
index 751c65c..0000000
--- a/apps/dashboard/assets/js/state.js
+++ /dev/null
@@ -1,60 +0,0 @@
-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: "system", status: "online", host_name: "nas", agent_name: "not_configured" },
- cpu: { usage_percent: 23, cores: 8, load_1: 0.8, load_5: 0.6, load_15: 0.5 },
- memory: { used_gb: 12.4, total_gb: 32.0, available_gb: 19.6, usage_percent: 38.7 },
- network: { primary_interface: "eth0", rx_mbps: 12.4, tx_mbps: 3.1 },
- host: { uptime_seconds: 864000, platform: "linux", kernel: "6.1.0" },
- },
- storage: {
- generated_at: new Date().toISOString(),
- summary: { total_used_gb: 2048, total_size_gb: 8192, total_free_gb: 6144, overall_usage_percent: 25 },
- disks: [],
- },
- services: {
- generated_at: new Date().toISOString(),
- summary: { overall_status: "online", total: 11, online: 8, degraded: 2, offline: 1 },
- docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
- uptime_kuma: { monitors_up: 8, monitors_down: 1, source_status: "online" },
- items: [],
- },
- adguard: { source_name: "adguard", source_status: "offline", total_queries: 0, blocked_queries: 0, blocked_percent: 0, avg_processing_ms: 0 },
- scrutiny: { source_name: "scrutiny", source_status: "offline", overall_status: "offline", devices: [], failed_count: 0, total_count: 0 },
- immich: { source_name: "immich", source_status: "offline", photos: 0, videos: 0, storage_gb: 0 },
- backrest: { source_name: "backrest", source_status: "offline", repo_count: 0, last_backup_age_hours: null, last_backup_status: "unknown", error_count: 0 },
- home_assistant: { source_name: "home_assistant", status: "offline", version: null, lights_on: 0, lights_total: 0, climate_active: 0, doors_open: 0, alerts: 0 },
- uptime_kuma: { source_name: "uptime_kuma", source_status: "offline", monitors_up: 0, monitors_down: 0, monitors_paused: 0, total: 0, monitors: [] },
-};
-
-let _state = structuredClone(DEFAULT_DATA);
-const _subscribers = [];
-
-export function getState() { return _state; }
-export function subscribe(fn) { _subscribers.push(fn); }
-export function updateData(partial) {
- _state = { ..._state, ...partial };
- _subscribers.forEach((fn) => fn(_state));
-}
diff --git a/apps/dashboard/backend/app/__init__.py b/apps/dashboard/backend/app/__init__.py
deleted file mode 100644
index 8285673..0000000
--- a/apps/dashboard/backend/app/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Homelab dashboard backend package."""
diff --git a/apps/dashboard/backend/app/clients/__init__.py b/apps/dashboard/backend/app/clients/__init__.py
deleted file mode 100644
index 5ce75ec..0000000
--- a/apps/dashboard/backend/app/clients/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""External system clients."""
diff --git a/apps/dashboard/backend/app/clients/adguard_client.py b/apps/dashboard/backend/app/clients/adguard_client.py
deleted file mode 100644
index 0deee9b..0000000
--- a/apps/dashboard/backend/app/clients/adguard_client.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from __future__ import annotations
-
-import logging
-
-from app.clients.base import BaseHTTPClient
-from app.config import Settings
-from app.models.sources import AdGuardSnapshot
-
-
-logger = logging.getLogger(__name__)
-
-
-class AdGuardClient(BaseHTTPClient):
- """
- Reads DNS statistics from AdGuard Home's /control/stats endpoint.
- Requires Basic Auth (username + password).
- """
-
- def __init__(self, settings: Settings) -> None:
- super().__init__(settings, "adguard", settings.adguard_base_url)
-
- async def fetch_stats(self) -> AdGuardSnapshot:
- snapshot = AdGuardSnapshot()
- if not self.base_url:
- logger.info("adguard skipped: base URL missing")
- return snapshot
-
- if not self.settings.adguard_username or not self.settings.adguard_password:
- logger.info("adguard skipped: no credentials configured")
- return snapshot
-
- data = await self._request_json(
- "GET",
- "/control/stats",
- auth=(self.settings.adguard_username, self.settings.adguard_password),
- )
-
- if data is None:
- logger.warning("adguard: empty or failed response")
- return snapshot
-
- total = int(data.get("num_dns_queries") or 0)
- blocked = int(data.get("num_blocked_filtering") or 0)
- avg_ms = round(float(data.get("avg_processing_time") or 0.0) * 1000, 2)
- blocked_pct = round((blocked / total * 100), 1) if total > 0 else 0.0
-
- result = AdGuardSnapshot(
- source_status="online",
- total_queries=total,
- blocked_queries=blocked,
- blocked_percent=blocked_pct,
- avg_processing_ms=avg_ms,
- )
- logger.info("adguard stats: %s", result.model_dump())
- return result
diff --git a/apps/dashboard/backend/app/clients/backrest_client.py b/apps/dashboard/backend/app/clients/backrest_client.py
deleted file mode 100644
index 5f4e6b1..0000000
--- a/apps/dashboard/backend/app/clients/backrest_client.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from __future__ import annotations
-
-import logging
-from datetime import datetime, timezone
-
-from app.clients.base import BaseHTTPClient
-from app.config import Settings
-from app.models.sources import BackrestSnapshot
-
-logger = logging.getLogger(__name__)
-
-
-class BackrestClient(BaseHTTPClient):
- def __init__(self, settings: Settings) -> None:
- super().__init__(settings, "backrest", settings.backrest_base_url)
-
- def _auth(self) -> tuple[str, str] | None:
- if self.settings.backrest_username and self.settings.backrest_password:
- return (self.settings.backrest_username, self.settings.backrest_password)
- return None
-
- async def fetch_status(self) -> BackrestSnapshot:
- snapshot = BackrestSnapshot()
- if not self.base_url:
- logger.info("backrest skipped: base URL missing")
- return snapshot
-
- auth = self._auth()
-
- # Get config (repo list) via Connect-RPC
- data = await self._request_json(
- "POST", "/v1.Backrest/GetConfig",
- json={},
- auth=auth,
- )
- if not isinstance(data, dict):
- return snapshot
-
- repos = data.get("repos") or []
- repo_count = len(repos) if isinstance(repos, list) else 0
-
- # Get recent operations via Connect-RPC
- last_backup_age_hours: float | None = None
- error_count = 0
- last_backup_status = "unknown"
-
- ops_data = await self._request_json(
- "POST", "/v1.Backrest/GetOperations",
- json={"lastN": 20},
- auth=auth,
- )
- if isinstance(ops_data, dict):
- ops = ops_data.get("operations") or []
- backup_ops = [
- op for op in ops
- if isinstance(op, dict) and op.get("backupOp") is not None
- ]
- error_ops = [op for op in backup_ops if op.get("status") == "STATUS_ERROR"]
- error_count = len(error_ops)
-
- if backup_ops:
- latest = backup_ops[0]
- ts = latest.get("unixTimeEndMs")
- if ts:
- ended = datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
- now = datetime.now(timezone.utc)
- last_backup_age_hours = round((now - ended).total_seconds() / 3600, 1)
- status_str = latest.get("status", "")
- if status_str == "STATUS_SUCCESS":
- last_backup_status = "ok"
- elif status_str == "STATUS_ERROR":
- last_backup_status = "error"
- else:
- last_backup_status = "unknown"
-
- return BackrestSnapshot(
- source_status="online",
- repo_count=repo_count,
- last_backup_age_hours=last_backup_age_hours,
- last_backup_status=last_backup_status,
- error_count=error_count,
- )
diff --git a/apps/dashboard/backend/app/clients/base.py b/apps/dashboard/backend/app/clients/base.py
deleted file mode 100644
index f697197..0000000
--- a/apps/dashboard/backend/app/clients/base.py
+++ /dev/null
@@ -1,111 +0,0 @@
-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,
- json: Any | None = None,
- ) -> Any | None:
- response = await self._request(
- method,
- path,
- headers=headers,
- params=params,
- auth=auth,
- json=json,
- )
- 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,
- json: Any | 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,
- json=json,
- )
- 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
deleted file mode 100644
index 05a7d25..0000000
--- a/apps/dashboard/backend/app/clients/beszel_client.py
+++ /dev/null
@@ -1,431 +0,0 @@
-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 and self._as_float(stats.get("dp")) > 0:
- disk_pct = self._as_float(stats.get("dp"))
- disk_used = self._as_float(stats.get("du"))
- disk_total = round(disk_used / (disk_pct / 100), 1) if disk_pct > 0 else 0.0
- disk_free = round(max(disk_total - disk_used, 0.0), 1)
- logger.info("beszel storage fallback: using dp=%.1f du=%.1f from stats", disk_pct, disk_used)
- disks = [BeszelDiskMetric(
- name="rootfs",
- mount="/",
- used_gb=disk_used,
- total_gb=disk_total,
- free_gb=disk_free,
- usage_percent=disk_pct,
- )]
- 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
deleted file mode 100644
index 950992c..0000000
--- a/apps/dashboard/backend/app/clients/docker_proxy_client.py
+++ /dev/null
@@ -1,103 +0,0 @@
-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
deleted file mode 100644
index d40a9ec..0000000
--- a/apps/dashboard/backend/app/clients/home_assistant_client.py
+++ /dev/null
@@ -1,83 +0,0 @@
-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)
- 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")
-
- # Fetch entity states for widget counts
- lights_on = 0
- lights_total = 0
- climate_active = 0
- doors_open = 0
- alerts = 0
-
- try:
- states = await self._request_json("GET", "/api/states", headers=headers)
- if isinstance(states, list):
- for entity in states:
- eid = entity.get("entity_id", "")
- state = entity.get("state", "")
- if eid.startswith("light."):
- lights_total += 1
- if state == "on":
- lights_on += 1
- elif eid.startswith("climate."):
- if state not in ("off", "unavailable", "unknown"):
- climate_active += 1
- elif eid.startswith("binary_sensor.") and "door" in eid:
- if state == "on":
- doors_open += 1
- elif eid.startswith("persistent_notification."):
- if state == "notifying":
- alerts += 1
- except Exception as exc:
- logger.warning("home assistant fetch states failed: %s", exc)
-
- normalized = HomeAssistantSnapshot(
- status="online",
- version=str(version) if version else None,
- response_time_ms=elapsed_ms,
- last_checked=datetime.now(timezone.utc),
- lights_on=lights_on,
- lights_total=lights_total,
- climate_active=climate_active,
- doors_open=doors_open,
- alerts=alerts,
- )
- logger.info("home assistant normalized snapshot: %s", normalized.model_dump())
- return normalized
diff --git a/apps/dashboard/backend/app/clients/immich_client.py b/apps/dashboard/backend/app/clients/immich_client.py
deleted file mode 100644
index 1a1d466..0000000
--- a/apps/dashboard/backend/app/clients/immich_client.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from __future__ import annotations
-
-import logging
-
-from app.clients.base import BaseHTTPClient
-from app.config import Settings
-from app.models.sources import ImmichSnapshot
-
-
-logger = logging.getLogger(__name__)
-
-
-class ImmichClient(BaseHTTPClient):
- def __init__(self, settings: Settings) -> None:
- super().__init__(settings, "immich", settings.immich_base_url)
-
- async def fetch_stats(self) -> ImmichSnapshot:
- snapshot = ImmichSnapshot()
- if not self.base_url or not self.settings.immich_api_key:
- logger.info("immich skipped: base URL or API key missing")
- return snapshot
-
- headers = {"x-api-key": self.settings.immich_api_key}
-
- try:
- data = await self._request_json("GET", "/api/server/statistics", headers=headers)
- except Exception as exc:
- logger.warning("immich fetch_stats failed: %s", exc)
- return snapshot
-
- if not isinstance(data, dict):
- logger.warning("immich unexpected response type: %s", type(data))
- return snapshot
-
- photos = int(data.get("photos", 0))
- videos = int(data.get("videos", 0))
- usage_bytes = int(data.get("usage", 0))
- usage_gb = round(usage_bytes / (1024 ** 3), 1)
-
- return ImmichSnapshot(
- source_status="online",
- photos=photos,
- videos=videos,
- storage_gb=usage_gb,
- )
diff --git a/apps/dashboard/backend/app/clients/scrutiny_client.py b/apps/dashboard/backend/app/clients/scrutiny_client.py
deleted file mode 100644
index bae7f83..0000000
--- a/apps/dashboard/backend/app/clients/scrutiny_client.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from __future__ import annotations
-
-import logging
-
-from app.clients.base import BaseHTTPClient
-from app.config import Settings
-from app.models.sources import ScrutinyDevice, ScrutinySnapshot
-
-
-logger = logging.getLogger(__name__)
-
-
-class ScrutinyClient(BaseHTTPClient):
- """
- Reads SMART disk health data from Scrutiny's /api/summary endpoint.
- No authentication required.
- """
-
- def __init__(self, settings: Settings) -> None:
- super().__init__(settings, "scrutiny", settings.scrutiny_base_url)
-
- async def fetch_summary(self) -> ScrutinySnapshot:
- snapshot = ScrutinySnapshot()
- if not self.base_url:
- logger.info("scrutiny skipped: base URL missing")
- return snapshot
-
- data = await self._request_json("GET", "/api/summary")
-
- if data is None:
- logger.warning("scrutiny: empty or failed response")
- return snapshot
-
- devices = self._parse_devices(data)
- failed = sum(1 for d in devices if d.status == "failed")
- overall: str = "online" if failed == 0 and len(devices) > 0 else ("offline" if failed > 0 else "offline")
-
- result = ScrutinySnapshot(
- source_status="online",
- devices=devices,
- overall_status=overall,
- failed_count=failed,
- total_count=len(devices),
- )
- logger.info("scrutiny summary: %s devices, %s failed", len(devices), failed)
- return result
-
- @staticmethod
- def _parse_devices(data: dict) -> list[ScrutinyDevice]:
- devices: list[ScrutinyDevice] = []
- summary: dict = (data.get("data") or {}).get("summary") or {}
-
- for device_path, device_data in summary.items():
- device_info: dict = device_data.get("device") or {}
- smart_data = device_data.get("smart") or []
-
- name = device_info.get("device_name") or device_path.split("/")[-1]
- model = device_info.get("model_name") or "Unknown"
-
- if isinstance(smart_data, dict):
- # smart is a dict keyed by timestamp strings; grab the most recent value
- latest_smart = smart_data[max(smart_data)] if smart_data else {}
- elif isinstance(smart_data, list):
- latest_smart = smart_data[-1] if smart_data else {}
- else:
- latest_smart = {}
- if not isinstance(latest_smart, dict):
- latest_smart = {}
- status_code = latest_smart.get("Status", -1)
- if status_code == 0:
- status = "passed"
- elif status_code > 0:
- status = "failed"
- else:
- status = "unknown"
-
- devices.append(ScrutinyDevice(name=name, model=model, status=status))
-
- return sorted(devices, key=lambda d: d.name)
diff --git a/apps/dashboard/backend/app/clients/uptime_kuma_client.py b/apps/dashboard/backend/app/clients/uptime_kuma_client.py
deleted file mode 100644
index 7bf5389..0000000
--- a/apps/dashboard/backend/app/clients/uptime_kuma_client.py
+++ /dev/null
@@ -1,190 +0,0 @@
-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 /metrics endpoint.
-
- Auth: once an API key exists in Uptime Kuma, username/password Basic Auth
- is permanently disabled. The correct format is HTTP Basic Auth with an
- empty username and the API key as the password: auth=("", api_key).
- """
-
- 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: no base URL configured")
- return snapshot
-
- raw_metrics: str | None = None
-
- # Primary: API key as Basic Auth password (empty username)
- if self.settings.uptime_kuma_api_key:
- raw_metrics = await self._request_metrics_with_mode(
- "api-key",
- auth=("", self.settings.uptime_kuma_api_key),
- )
-
- # Fallback: regular username/password (only works if no API keys exist)
- if raw_metrics is None 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=monitors,
- )
- logger.info("uptime kuma normalized snapshot: %s", normalized.model_dump())
- return normalized
-
- async def _request_metrics_with_mode(
- self,
- mode: str,
- *,
- 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, 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 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") or labels.get("monitor_name")
- monitor_name = labels.get("monitor_name") or labels.get("name")
- if not monitor_id or not monitor_name:
- continue
- if monitor_id not in status_by_id:
- status_by_id[monitor_id] = UptimeKumaMonitor(
- id=self._as_int(monitor_id),
- name=monitor_name,
- )
- monitor = status_by_id[monitor_id]
- if metric_name == "monitor_status":
- status_code = self._as_int_from_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
- elif metric_name == "monitor_uptime":
- duration = labels.get("duration", "")
- if duration == "24":
- uptime = self._as_float(raw_value)
- if 0.0 <= uptime <= 1.0:
- monitor.uptime_24h = round(uptime * 100, 1)
-
- # Build synthetic heartbeat bar (20 segments) from uptime_24h
- for monitor in status_by_id.values():
- if not monitor.heartbeats:
- if monitor.uptime_24h >= 99.9:
- monitor.heartbeats = [1] * 20
- elif monitor.uptime_24h <= 0.1:
- monitor.heartbeats = [0] * 20
- else:
- green_count = round(monitor.uptime_24h / 100 * 20)
- monitor.heartbeats = [0] * (20 - green_count) + [1] * green_count
-
- 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, TypeError):
- return -1.0
-
- @staticmethod
- def _as_int(value: str) -> int:
- try:
- return int(value)
- except (ValueError, TypeError):
- return 0
-
- @staticmethod
- def _as_int_from_float(value: str) -> int:
- try:
- return int(float(value))
- except (ValueError, TypeError):
- return 0
diff --git a/apps/dashboard/backend/app/config.py b/apps/dashboard/backend/app/config.py
deleted file mode 100644
index 9e6bc56..0000000
--- a/apps/dashboard/backend/app/config.py
+++ /dev/null
@@ -1,79 +0,0 @@
-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"])
-
- cache_ttl_overview_seconds: int = Field(default=20, ge=1)
- cache_ttl_system_seconds: int = Field(default=10, ge=1)
- cache_ttl_services_seconds: int = Field(default=15, ge=1)
- cache_ttl_storage_seconds: int = Field(default=30, ge=1)
-
- request_timeout_seconds: float = Field(default=5.0, gt=0)
-
- 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
-
- adguard_base_url: HttpUrl | None = None
- adguard_username: str | None = None
- adguard_password: str | None = None
-
- scrutiny_base_url: HttpUrl | None = None
-
- immich_base_url: HttpUrl | None = None
- immich_api_key: str | None = None
-
- backrest_base_url: HttpUrl | None = None
- backrest_username: str | None = None
- backrest_password: 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
deleted file mode 100644
index 9013c98..0000000
--- a/apps/dashboard/backend/app/main.py
+++ /dev/null
@@ -1,93 +0,0 @@
-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.adguard import router as adguard_router
-from app.routes.backrest import router as backrest_router
-from app.routes.home_assistant import router as home_assistant_router
-from app.routes.immich import router as immich_router
-from app.routes.overview import router as overview_router
-from app.routes.scrutiny import router as scrutiny_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
-from app.routes.uptime_kuma import router as uptime_kuma_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 IMMICH_BASE_URL=%s BACKREST_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),
- bool(settings.immich_base_url),
- bool(settings.backrest_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)
-app.include_router(adguard_router)
-app.include_router(scrutiny_router)
-app.include_router(immich_router)
-app.include_router(backrest_router)
-app.include_router(home_assistant_router)
-app.include_router(uptime_kuma_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
deleted file mode 100644
index 565057e..0000000
--- a/apps/dashboard/backend/app/models/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""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
deleted file mode 100644
index dadbf4f..0000000
--- a/apps/dashboard/backend/app/models/common.py
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index 9748014..0000000
--- a/apps/dashboard/backend/app/models/overview.py
+++ /dev/null
@@ -1,44 +0,0 @@
-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
deleted file mode 100644
index d142be1..0000000
--- a/apps/dashboard/backend/app/models/services.py
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index 314fd3d..0000000
--- a/apps/dashboard/backend/app/models/sources.py
+++ /dev/null
@@ -1,132 +0,0 @@
-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 = "system"
- source_status: SourceStatus = "offline"
- host_name: str = "unknown"
- agent_name: str = "not_configured"
- 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"
- containers: list[DockerContainerSummary] = Field(default_factory=list)
- running: int = 0
- stopped: int = 0
- unhealthy: int = 0
- total: int = 0
-
-
-class UptimeKumaMonitor(APIModel):
- id: int
- name: str
- status: str = "unknown" # "online" | "offline" | "degraded" | "unknown"
- uptime_24h: float = 0.0
- heartbeats: list[int] = Field(default_factory=list) # 1=up, 0=down, last 20
-
-
-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
- lights_on: int = 0
- lights_total: int = 0
- climate_active: int = 0
- doors_open: int = 0
- alerts: int = 0
-
-
-class AdGuardSnapshot(APIModel):
- source_name: str = "adguard"
- source_status: SourceStatus = "offline"
- total_queries: int = 0
- blocked_queries: int = 0
- blocked_percent: float = 0.0
- avg_processing_ms: float = 0.0
-
-
-class ScrutinyDevice(APIModel):
- name: str
- model: str
- status: str = "unknown" # "passed" | "failed" | "unknown"
- temperature: int | None = None
-
-
-class ScrutinySnapshot(APIModel):
- source_name: str = "scrutiny"
- source_status: SourceStatus = "offline"
- overall_status: str = "offline"
- devices: list[ScrutinyDevice] = Field(default_factory=list)
- failed_count: int = 0
- total_count: int = 0
-
-
-class ImmichSnapshot(APIModel):
- source_name: str = "immich"
- source_status: SourceStatus = "offline"
- photos: int = 0
- videos: int = 0
- storage_gb: float = 0.0
-
-
-class BackrestSnapshot(APIModel):
- source_name: str = "backrest"
- source_status: SourceStatus = "offline"
- repo_count: int = 0
- last_backup_age_hours: float | None = None
- last_backup_status: str = "unknown" # "ok" | "error" | "unknown"
- error_count: int = 0
diff --git a/apps/dashboard/backend/app/models/storage.py b/apps/dashboard/backend/app/models/storage.py
deleted file mode 100644
index 2c02c1c..0000000
--- a/apps/dashboard/backend/app/models/storage.py
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index f3222d1..0000000
--- a/apps/dashboard/backend/app/models/system.py
+++ /dev/null
@@ -1,45 +0,0 @@
-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
deleted file mode 100644
index fb0a2f8..0000000
--- a/apps/dashboard/backend/app/routes/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""API route modules."""
diff --git a/apps/dashboard/backend/app/routes/adguard.py b/apps/dashboard/backend/app/routes/adguard.py
deleted file mode 100644
index a71d0fd..0000000
--- a/apps/dashboard/backend/app/routes/adguard.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends
-
-from app.models.sources import AdGuardSnapshot
-from app.services.aggregator import AggregatorService, get_aggregator_service
-
-
-router = APIRouter(prefix="/api", tags=["adguard"])
-
-
-@router.get("/adguard", response_model=AdGuardSnapshot)
-async def get_adguard(
- aggregator: AggregatorService = Depends(get_aggregator_service),
-) -> AdGuardSnapshot:
- return await aggregator.get_adguard()
diff --git a/apps/dashboard/backend/app/routes/backrest.py b/apps/dashboard/backend/app/routes/backrest.py
deleted file mode 100644
index 33cdbd7..0000000
--- a/apps/dashboard/backend/app/routes/backrest.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends
-
-from app.models.sources import BackrestSnapshot
-from app.services.aggregator import AggregatorService, get_aggregator_service
-
-
-router = APIRouter(prefix="/api", tags=["backrest"])
-
-
-@router.get("/backrest", response_model=BackrestSnapshot)
-async def get_backrest(
- aggregator: AggregatorService = Depends(get_aggregator_service),
-) -> BackrestSnapshot:
- return await aggregator.get_backrest()
diff --git a/apps/dashboard/backend/app/routes/home_assistant.py b/apps/dashboard/backend/app/routes/home_assistant.py
deleted file mode 100644
index f1ef9fe..0000000
--- a/apps/dashboard/backend/app/routes/home_assistant.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends
-
-from app.models.sources import HomeAssistantSnapshot
-from app.services.aggregator import AggregatorService, get_aggregator_service
-
-
-router = APIRouter(prefix="/api", tags=["home_assistant"])
-
-
-@router.get("/home_assistant", response_model=HomeAssistantSnapshot)
-async def get_home_assistant(
- aggregator: AggregatorService = Depends(get_aggregator_service),
-) -> HomeAssistantSnapshot:
- return await aggregator.get_home_assistant()
diff --git a/apps/dashboard/backend/app/routes/immich.py b/apps/dashboard/backend/app/routes/immich.py
deleted file mode 100644
index 7b926fe..0000000
--- a/apps/dashboard/backend/app/routes/immich.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends
-
-from app.models.sources import ImmichSnapshot
-from app.services.aggregator import AggregatorService, get_aggregator_service
-
-
-router = APIRouter(prefix="/api", tags=["immich"])
-
-
-@router.get("/immich", response_model=ImmichSnapshot)
-async def get_immich(
- aggregator: AggregatorService = Depends(get_aggregator_service),
-) -> ImmichSnapshot:
- return await aggregator.get_immich()
diff --git a/apps/dashboard/backend/app/routes/overview.py b/apps/dashboard/backend/app/routes/overview.py
deleted file mode 100644
index ccf33c5..0000000
--- a/apps/dashboard/backend/app/routes/overview.py
+++ /dev/null
@@ -1,16 +0,0 @@
-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/scrutiny.py b/apps/dashboard/backend/app/routes/scrutiny.py
deleted file mode 100644
index f037a8e..0000000
--- a/apps/dashboard/backend/app/routes/scrutiny.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends
-
-from app.models.sources import ScrutinySnapshot
-from app.services.aggregator import AggregatorService, get_aggregator_service
-
-
-router = APIRouter(prefix="/api", tags=["scrutiny"])
-
-
-@router.get("/scrutiny", response_model=ScrutinySnapshot)
-async def get_scrutiny(
- aggregator: AggregatorService = Depends(get_aggregator_service),
-) -> ScrutinySnapshot:
- return await aggregator.get_scrutiny()
diff --git a/apps/dashboard/backend/app/routes/services.py b/apps/dashboard/backend/app/routes/services.py
deleted file mode 100644
index b61032b..0000000
--- a/apps/dashboard/backend/app/routes/services.py
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index b74fbc4..0000000
--- a/apps/dashboard/backend/app/routes/storage.py
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index 519e2eb..0000000
--- a/apps/dashboard/backend/app/routes/system.py
+++ /dev/null
@@ -1,16 +0,0 @@
-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/routes/uptime_kuma.py b/apps/dashboard/backend/app/routes/uptime_kuma.py
deleted file mode 100644
index 92d4a41..0000000
--- a/apps/dashboard/backend/app/routes/uptime_kuma.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends
-
-from app.models.sources import UptimeKumaSnapshot
-from app.services.aggregator import AggregatorService, get_aggregator_service
-
-
-router = APIRouter(prefix="/api", tags=["uptime_kuma"])
-
-
-@router.get("/uptime_kuma", response_model=UptimeKumaSnapshot)
-async def get_uptime_kuma(
- aggregator: AggregatorService = Depends(get_aggregator_service),
-) -> UptimeKumaSnapshot:
- return await aggregator.get_uptime_kuma()
diff --git a/apps/dashboard/backend/app/services/__init__.py b/apps/dashboard/backend/app/services/__init__.py
deleted file mode 100644
index a69ee20..0000000
--- a/apps/dashboard/backend/app/services/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Application services."""
diff --git a/apps/dashboard/backend/app/services/aggregator.py b/apps/dashboard/backend/app/services/aggregator.py
deleted file mode 100644
index 3902b41..0000000
--- a/apps/dashboard/backend/app/services/aggregator.py
+++ /dev/null
@@ -1,429 +0,0 @@
-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.adguard_client import AdGuardClient
-from app.clients.backrest_client import BackrestClient
-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.immich_client import ImmichClient
-from app.clients.scrutiny_client import ScrutinyClient
-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 (
- AdGuardSnapshot,
- BackrestSnapshot,
- BeszelDiskMetric,
- BeszelSystemSnapshot,
- DockerSnapshot,
- HomeAssistantSnapshot,
- ImmichSnapshot,
- ScrutinySnapshot,
- 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,
- adguard_client: AdGuardClient,
- scrutiny_client: ScrutinyClient,
- immich_client: ImmichClient,
- backrest_client: BackrestClient,
- ) -> 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
- self.adguard_client = adguard_client
- self.scrutiny_client = scrutiny_client
- self.immich_client = immich_client
- self.backrest_client = backrest_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_adguard(self) -> AdGuardSnapshot:
- return await self.cache.get_or_load(
- "adguard",
- self.settings.cache_ttl_services_seconds,
- self.adguard_client.fetch_stats,
- )
-
- async def get_scrutiny(self) -> ScrutinySnapshot:
- return await self.cache.get_or_load(
- "scrutiny",
- self.settings.cache_ttl_storage_seconds,
- self.scrutiny_client.fetch_summary,
- )
-
- async def get_immich(self) -> ImmichSnapshot:
- return await self.cache.get_or_load(
- "immich",
- self.settings.cache_ttl_services_seconds,
- self.immich_client.fetch_stats,
- )
-
- async def get_backrest(self) -> BackrestSnapshot:
- return await self.cache.get_or_load(
- "backrest",
- self.settings.cache_ttl_services_seconds,
- self.backrest_client.fetch_status,
- )
-
- async def get_home_assistant(self) -> HomeAssistantSnapshot:
- return await self.cache.get_or_load(
- "home_assistant",
- self.settings.cache_ttl_services_seconds,
- self.home_assistant_client.fetch_status,
- )
-
- async def get_uptime_kuma(self) -> UptimeKumaSnapshot:
- return await self.cache.get_or_load(
- "uptime_kuma",
- self.settings.cache_ttl_services_seconds,
- self.uptime_kuma_client.fetch_monitors,
- )
-
- 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(d, snapshot.source_status) for d 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((d for d in disks if d.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 d in disks if d.status == "critical")
- warning_count = sum(1 for d in disks if d.status == "warning")
- overall_status: OverallStatus = (
- "offline" if snapshot.source_status == "offline"
- else ("degraded" if critical_count or warning_count 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_snap, uk_snap = await asyncio.gather(
- self.docker_client.fetch_containers(),
- self.uptime_kuma_client.fetch_monitors(),
- )
- now = datetime.now(timezone.utc)
-
- monitor_by_name = {
- self._normalize_identifier(m.name): m for m in uk_snap.monitors
- }
- docker_by_name = {
- self._normalize_identifier(c.name): c for c in docker_snap.containers
- }
-
- items: list[ServiceItem] = []
- merged_names = sorted(set(docker_by_name) | set(monitor_by_name))
- for norm in merged_names:
- container = docker_by_name.get(norm)
- monitor = monitor_by_name.get(norm)
- status = self._resolve_overall_status(
- container.state if container else "unknown", monitor
- )
- items.append(ServiceItem(
- id=norm,
- 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=now.isoformat(),
- ))
-
- statuses = [i.status for i in items]
- overall = self._aggregate_statuses(statuses)
-
- return ServicesResponse(
- generated_at=now,
- summary=ServicesSummary(
- overall_status=overall,
- docker=ServicesDockerSummary(
- running=docker_snap.running,
- stopped=docker_snap.stopped,
- unhealthy=docker_snap.unhealthy,
- total=docker_snap.total,
- source_status=docker_snap.source_status,
- ),
- uptime_kuma=ServicesUptimeKumaSummary(
- monitors_up=uk_snap.monitors_up,
- monitors_down=uk_snap.monitors_down,
- monitors_paused=uk_snap.monitors_paused,
- total=uk_snap.total,
- source_status=uk_snap.source_status,
- ),
- ),
- services=items,
- )
-
- async def _build_overview(self) -> OverviewResponse:
- system_snap, docker_snap, uk_snap, ha_snap = await asyncio.gather(
- self.beszel_client.fetch_system_snapshot(),
- self.docker_client.fetch_containers(),
- self.uptime_kuma_client.fetch_monitors(),
- self.home_assistant_client.fetch_status(),
- )
- now = datetime.now(timezone.utc)
-
- statuses: list[OverallStatus] = []
- for container in docker_snap.containers:
- name_lower = self._normalize_identifier(container.name)
- monitor = next(
- (m for m in uk_snap.monitors if self._normalize_identifier(m.name) == name_lower),
- None,
- )
- statuses.append(self._resolve_overall_status(container.state, monitor))
-
- overall = self._aggregate_statuses(statuses)
-
- return OverviewResponse(
- generated_at=now,
- overall_status=overall,
- refresh_hint_seconds=self.settings.cache_ttl_overview_seconds,
- services=OverviewServicesSummary(
- online=sum(1 for s in statuses if s == "online"),
- degraded=sum(1 for s in statuses if s == "degraded"),
- offline=sum(1 for s in statuses if s == "offline"),
- total=len(statuses),
- ),
- docker=OverviewDockerSummary(
- running=docker_snap.running,
- stopped=docker_snap.stopped,
- unhealthy=docker_snap.unhealthy,
- total=docker_snap.total,
- source_status=docker_snap.source_status,
- ),
- system=OverviewSystemSummary(
- cpu_percent=system_snap.cpu_usage_percent,
- ram_percent=system_snap.memory_usage_percent,
- root_storage_percent=system_snap.disks[0].usage_percent if system_snap.disks else 0.0,
- network_rx_mbps=system_snap.network_rx_mbps,
- network_tx_mbps=system_snap.network_tx_mbps,
- uptime_seconds=system_snap.uptime_seconds,
- ),
- home_assistant=OverviewHomeAssistantSummary(
- status=ha_snap.status,
- label=ha_snap.label,
- version=ha_snap.version,
- response_time_ms=ha_snap.response_time_ms,
- last_checked=ha_snap.last_checked.isoformat() if ha_snap.last_checked else None,
- ),
- )
-
- @staticmethod
- def _normalize_identifier(value: str) -> str:
- return "".join(ch.lower() for ch in value if ch.isalnum())
-
- @staticmethod
- def _resolve_overall_status(
- 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,
- )
-
- @staticmethod
- def _aggregate_statuses(statuses: Iterable[OverallStatus]) -> OverallStatus:
- normalized = list(statuses)
- if not normalized:
- return "offline"
- if any(s == "offline" for s in normalized):
- return "degraded" if any(s == "online" for s in normalized) else "offline"
- if any(s in {"degraded", "warning", "critical"} for s 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),
- adguard_client=AdGuardClient(settings),
- scrutiny_client=ScrutinyClient(settings),
- immich_client=ImmichClient(settings),
- backrest_client=BackrestClient(settings),
- )
diff --git a/apps/dashboard/backend/app/services/cache.py b/apps/dashboard/backend/app/services/cache.py
deleted file mode 100644
index f2d5c3e..0000000
--- a/apps/dashboard/backend/app/services/cache.py
+++ /dev/null
@@ -1,60 +0,0 @@
-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
deleted file mode 100644
index 410a24c..0000000
--- a/apps/dashboard/backend/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-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
deleted file mode 100644
index 783dca3..0000000
--- a/apps/dashboard/dashboard.html
+++ /dev/null
@@ -1,953 +0,0 @@
-
-
-
-
-
-KalliLab Control Panel
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- HOME ASSISTANT
-
-
OFFLINE
-
-
-
-
-
-
-
-
- UPTIME KUMA
-
-
OFFLINE
-
-
-
-
-
-
-
-
- IMMICH
-
-
OFFLINE
-
-
-
-
-
-
-
- BACKREST
-
-
-
OFFLINE
-
-
-
-
-
-
-
- ADGUARD DNS
-
-
OFFLINE
-
-
-
-
-
-
-
-
- SERVICES OVERVIEW
-
-
—
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/dashboard/docker-compose.yml b/apps/dashboard/docker-compose.yml
deleted file mode 100644
index 87d17be..0000000
--- a/apps/dashboard/docker-compose.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-services:
- dashboard:
- image: ${DASHBOARD_IMAGE}
- 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
- UPTIME_KUMA_BASE_URL: http://uptime-kuma:3001
- UPTIME_KUMA_API_KEY: ${UPTIME_KUMA_API_KEY}
- 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}
- ADGUARD_BASE_URL: http://adguard:80
- ADGUARD_USERNAME: ${ADGUARD_USERNAME}
- ADGUARD_PASSWORD: ${ADGUARD_PASSWORD}
- SCRUTINY_BASE_URL: http://scrutiny:8080
- IMMICH_BASE_URL: http://immich_server:2283
- IMMICH_API_KEY: ${IMMICH_API_KEY}
- BACKREST_BASE_URL: http://backrest:9898
- BACKREST_USERNAME: ${BACKREST_USERNAME}
- BACKREST_PASSWORD: ${BACKREST_PASSWORD}
- 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
diff --git a/security/authelia/configuration.yml b/security/authelia/configuration.yml
index 0645972..4a6d756 100644
--- a/security/authelia/configuration.yml
+++ b/security/authelia/configuration.yml
@@ -38,7 +38,7 @@ access_control:
- vault.kaleschke.info
- ntfy.kaleschke.info
- git.kaleschke.info
- - homepage.kaleschke.info
+ - home.kaleschke.info
policy: bypass
# Admin-Dienste - 2FA erforderlich
@@ -62,7 +62,7 @@ session:
cookies:
- domain: kaleschke.info
authelia_url: https://auth.kaleschke.info
- default_redirection_url: https://homepage.kaleschke.info
+ default_redirection_url: https://home.kaleschke.info
regulation:
max_retries: 3