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
-
${warningCount}
Warning
-
${criticalCount}
Critical
-
${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 - - - - -
-
- - - - - -
SYSTEM
-
-
-
-
CPU
- OK -
-
-
Usage %
-
Cores
-
Load 5m
-
-
-
Compute Load0%
-
-
-
-
-
-
-
MEMORY
- OK -
-
-
Usage %
-
Used GB
-
Total GB
-
-
-
Memory Pool0%
-
-
-
-
-
-
-
NETWORK
- OK -
-
-
↓ Mbps
-
↑ Mbps
-
-
-
Traffic Flow0 Mbps
-
-
-
-
-
-
-
HOST
- OK -
-
-
Uptime d
-
OS
-
-
-
Host Runtime
-
-
-
-
-
-
-
DOCKER
- OK -
-
-
Running
-
Stopped
-
Total
-
-
-
Runtime Surface0%
-
-
-
-
-
- - -
STORAGE & HEALTH
-
-
-
- -
-
-
-
-
SCRUTINY
- OFFLINE -
-
-
Disks
-
Passed
-
Failed
-
-
-
-
- - -
SERVICES
-
-
-
-
- - HOME ASSISTANT -
- OFFLINE -
-
-
Lights
-
Climate
-
Doors
-
Alerts
-
- -
-
-
-
- - UPTIME KUMA -
- OFFLINE -
-
-
Up
-
Down
-
Paused
-
24h %
-
- -
-
-
-
- - IMMICH -
- OFFLINE -
-
-
Photos
-
Videos
-
Storage
-
-
-
-
-
- - BACKREST - -
- OFFLINE -
-
-
Last Backup
-
Repos
-
Errors
-
-
-
-
-
- - ADGUARD DNS -
- OFFLINE -
-
-
Queries
-
Blocked
-
Block %
-
Latency
-
-
-
-
-
-
- - SERVICES OVERVIEW -
- -
-
-
Online
-
Degraded
-
Offline
-
Total
-
-
-
- - -
QUICK ACCESS
-
- -
- - - - - 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