Add custom homelab dashboard stack

This commit is contained in:
2026-04-05 13:43:03 +02:00
parent 450a04a7d3
commit 89b9173c25
38 changed files with 3539 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
backend/.env
backend/app/__pycache__
backend/app/**/*.pyc
assets/.DS_Store
+21
View File
@@ -0,0 +1,21 @@
APP_ENV=production
APP_HOST=0.0.0.0
APP_PORT=8000
APP_LOG_LEVEL=INFO
APP_TIMEZONE=Europe/Berlin
APP_NAME=Homelab Dashboard API
APP_VERSION=0.1.0
CORS_ALLOW_ORIGINS=["https://dashboard.kaleschke.info"]
REQUEST_TIMEOUT_SECONDS=5.0
CACHE_TTL_OVERVIEW_SECONDS=15
CACHE_TTL_SYSTEM_SECONDS=15
CACHE_TTL_SERVICES_SECONDS=15
CACHE_TTL_STORAGE_SECONDS=30
BESZEL_BASE_URL=http://beszel:8090
BESZEL_ADMIN_EMAIL=
BESZEL_ADMIN_PASSWORD=
UPTIME_KUMA_BASE_URL=http://uptime-kuma:3001
UPTIME_KUMA_USERNAME=
UPTIME_KUMA_PASSWORD=
HOME_ASSISTANT_BASE_URL=http://192.168.178.50:8123
HOME_ASSISTANT_TOKEN=
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app/backend
COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
WORKDIR /app
COPY dashboard.html ./dashboard.html
COPY assets ./assets
WORKDIR /app/backend
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+27
View File
@@ -0,0 +1,27 @@
const DEFAULT_HEADERS = {
Accept: "application/json",
};
async function fetchJson(path) {
const response = await fetch(path, {
headers: DEFAULT_HEADERS,
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Request failed for ${path}: ${response.status}`);
}
return response.json();
}
export async function fetchDashboardData() {
const [overview, system, services, storage] = await Promise.all([
fetchJson("/api/overview"),
fetchJson("/api/system"),
fetchJson("/api/services"),
fetchJson("/api/storage"),
]);
return { overview, system, services, storage };
}
+46
View File
@@ -0,0 +1,46 @@
import { fetchDashboardData } from "./api.js";
import { getState, setError, subscribe, updateData } from "./state.js";
import { renderHeader } from "./renderers/header.js";
import { renderQuickAccess } from "./renderers/quick-access.js";
import { renderServices } from "./renderers/services.js";
import { renderStats } from "./renderers/stats.js";
import { renderStorage } from "./renderers/storage.js";
let pollTimer = null;
function render(state) {
renderHeader(state);
renderStats(state);
renderStorage(state);
renderServices(state);
}
async function refreshData() {
try {
const payload = await fetchDashboardData();
updateData(payload);
} catch (error) {
console.error("Dashboard refresh failed", error);
setError(error instanceof Error ? error : new Error("Unknown dashboard refresh error"));
} finally {
restartPolling();
}
}
function restartPolling() {
if (pollTimer) {
window.clearInterval(pollTimer);
}
const state = window.__dashboardState?.() ?? null;
const interval = state?.refreshIntervalMs ?? 20000;
pollTimer = window.setInterval(refreshData, interval);
}
subscribe((state) => {
window.__dashboardState = () => state;
render(state);
});
renderQuickAccess();
refreshData();
window.setInterval(() => renderHeader(getState()), 1000);
@@ -0,0 +1,87 @@
function formatTime(now) {
return new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
}
function formatDate(now) {
return new Intl.DateTimeFormat("de-DE", {
weekday: "long",
day: "2-digit",
month: "short",
year: "numeric",
}).format(now).toUpperCase();
}
function statusTone(status) {
if (status === "offline") return "offline";
if (status === "degraded") return "warning";
return "online";
}
function statusLabel(status) {
if (status === "offline") return "OFFLINE";
if (status === "degraded") return "DEGRADED";
return "NOMINAL";
}
export function renderHeader(state) {
const now = new Date();
const { overview, services } = state.data;
const refreshNode = document.getElementById("last-refresh");
const overallTile = document.getElementById("overall-status-tile");
const overallLabel = document.getElementById("overall-status-label");
const overallSummary = document.getElementById("overall-status-summary");
const haTile = document.getElementById("home-assistant-tile");
const haLabel = document.getElementById("ha-status-label");
const haSummary = document.getElementById("ha-status-summary");
const heroCopy = document.getElementById("hero-copy");
document.getElementById("clock-time").textContent = formatTime(now);
document.getElementById("clock-date").textContent = formatDate(now);
if (refreshNode) {
if (state.lastRefreshAt) {
refreshNode.textContent = `LAST REFRESH ${new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(state.lastRefreshAt)} CET`;
} else {
refreshNode.textContent = "LAST REFRESH PENDING";
}
refreshNode.classList.toggle("api-error", Boolean(state.error));
}
const overallTone = statusTone(overview.overall_status);
overallTile.className = "status-tile";
overallTile.style.borderColor = "";
overallLabel.textContent = statusLabel(overview.overall_status);
overallLabel.className = "";
overallSummary.textContent =
`${overview.services.online} services online, ${overview.services.degraded} degraded, ${overview.services.offline} offline`;
overallTile.classList.toggle("api-error", Boolean(state.error));
overallLabel.classList.add(`value-${overallTone === "warning" ? "warning" : overallTone === "offline" ? "danger" : "online"}`);
const haTone = statusTone(overview.home_assistant.status);
haLabel.textContent = overview.home_assistant.status === "online" ? "ONLINE" : "OFFLINE";
haLabel.className = "";
haLabel.classList.add(`value-${haTone === "warning" ? "warning" : haTone === "offline" ? "danger" : "online"}`);
haSummary.textContent =
overview.home_assistant.status === "online"
? `Version ${overview.home_assistant.version ?? "unknown"} / ${overview.home_assistant.response_time_ms ?? "n/a"} ms`
: "Basisstatus derzeit nicht erreichbar";
haTile.className = "status-tile";
if (state.error) {
heroCopy.textContent = `Aggregator API zuletzt fehlerhaft erreichbar. Letzte gueltige Daten bleiben sichtbar. Fehler: ${state.error.message}`;
} else {
heroCopy.textContent =
`Live angebundenes MVP-v1 Dashboard. ${services.summary.docker.running} Container running, ` +
`${services.summary.uptime_kuma.monitors_up} Kuma Monitors up, Home Assistant ${overview.home_assistant.status}.`;
}
}
@@ -0,0 +1,24 @@
const QUICK_LINKS = [
{ section: "Core", title: "Home Assistant", label: "OPEN CONTROL HUB", url: "http://localhost:8123" },
{ section: "Ops", title: "Uptime Kuma", label: "OPEN MONITORS", url: "http://localhost:3001" },
{ section: "Containers", title: "Portainer", label: "OPEN RUNTIME", url: "http://localhost:9000" },
{ section: "Media", title: "Immich", label: "OPEN GALLERY", url: "http://localhost:2283" },
];
export function renderQuickAccess() {
const grid = document.getElementById("quick-grid");
grid.innerHTML = QUICK_LINKS.map(
(link) => `
<article class="card quick-card">
<div class="card-inner">
<span class="card-label">${link.section}</span>
<h2 class="card-title">${link.title}</h2>
<a href="${link.url}" target="_blank" rel="noreferrer">
<span>${link.label}</span>
<span>&gt;</span>
</a>
</div>
</article>
`,
).join("");
}
@@ -0,0 +1,90 @@
function serviceTone(status) {
if (status === "offline") return "offline";
if (status === "degraded") return "warning";
return "online";
}
function healthLabel(status) {
if (status === "offline") return "Offline";
if (status === "degraded") return "Degraded";
return "Healthy";
}
function sourceLabel(source) {
if (source === "home_assistant") return "Core Automation Hub";
if (source === "uptime_kuma") return "External availability and latency surface.";
if (source === "docker") return "Container runtime state without external monitor data.";
return "Service state from aggregator.";
}
function formatTimestamp(value) {
if (!value) return "n/a";
const date = new Date(value);
return new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}
export function renderServices(state) {
const { services, overview } = state.data;
const dockerPill = document.getElementById("docker-summary-pill");
dockerPill.className = `status-pill ${services.summary.docker.source_status === "online" ? "online" : "offline"}`;
dockerPill.textContent = services.summary.docker.source_status === "online" ? "Online" : "Offline";
document.getElementById("docker-running").textContent = String(services.summary.docker.running);
document.getElementById("docker-stopped").textContent = String(services.summary.docker.stopped);
document.getElementById("docker-unhealthy").textContent = String(services.summary.docker.unhealthy);
const kumaPill = document.getElementById("kuma-summary-pill");
kumaPill.className = `status-pill ${services.summary.uptime_kuma.source_status === "online" ? "online" : "offline"}`;
kumaPill.textContent = services.summary.uptime_kuma.source_status === "online" ? "Synced" : "Offline";
document.getElementById("kuma-up").textContent = String(services.summary.uptime_kuma.monitors_up);
document.getElementById("kuma-down").textContent = String(services.summary.uptime_kuma.monitors_down);
document.getElementById("kuma-paused").textContent = String(services.summary.uptime_kuma.monitors_paused);
const ha = services.services.find((service) => service.id === "homeassistant");
if (ha) {
const haPill = document.getElementById("service-ha-pill");
const tone = serviceTone(ha.status);
haPill.className = `status-pill ${tone}`;
haPill.textContent = ha.status === "online" ? "Reachable" : ha.status === "degraded" ? "Degraded" : "Offline";
document.getElementById("service-ha-version").textContent = overview.home_assistant.version ?? "unknown";
document.getElementById("service-ha-version").className = "info";
document.getElementById("service-ha-latency").textContent = ha.latency_ms != null ? `${ha.latency_ms} MS` : "N/A";
document.getElementById("service-ha-latency").className = tone === "offline" ? "offline" : tone === "warning" ? "warning" : "online";
document.getElementById("service-ha-last-check").textContent = formatTimestamp(ha.last_checked);
}
const dynamicServices = services.services.filter((service) => service.id !== "homeassistant").slice(0, 3);
const existingFallbacks = [
document.getElementById("service-card-fallback-1"),
document.getElementById("service-card-fallback-2"),
document.getElementById("service-card-fallback-3"),
];
dynamicServices.forEach((service, index) => {
const node = existingFallbacks[index];
if (!node) return;
node.style.display = "";
const tone = serviceTone(service.status);
const pillClass = tone === "warning" ? "warning" : tone === "offline" ? "offline" : "online";
node.querySelector(".card-label").textContent = service.name;
node.querySelector(".status-pill").className = `status-pill ${pillClass}`;
node.querySelector(".status-pill").textContent = healthLabel(service.status);
node.querySelector(".card-title").textContent = service.name === "Immich" ? "Photo Pipeline" : service.name === "Gitea" ? "Git Platform" : `${service.name} Service`;
node.querySelector(".card-copy").textContent = sourceLabel(service.source);
const rows = node.querySelectorAll(".service-meta-row strong");
rows[0].textContent = service.latency_ms != null ? `${service.latency_ms} MS` : "N/A";
rows[0].className = tone === "offline" ? "offline" : tone === "warning" ? "warning" : "online";
rows[1].textContent = String(service.docker_state).toUpperCase();
rows[1].className = service.docker_state === "stopped" ? "offline" : service.docker_state === "unhealthy" ? "warning" : "online";
});
for (let index = dynamicServices.length; index < existingFallbacks.length; index += 1) {
existingFallbacks[index].style.display = "none";
}
}
@@ -0,0 +1,65 @@
function pct(value) {
return `${Math.round(value)}%`;
}
function gb(value) {
return `${Number(value).toFixed(1)} GB`;
}
function toneForPercent(value) {
if (value >= 85) return "danger";
if (value >= 60) return "warning";
return "online";
}
function setSignal(id, tone) {
const node = document.getElementById(id);
node.className = `signal ${tone === "danger" ? "offline" : tone === "warning" ? "warning" : tone === "info" ? "info" : "online"}`;
}
function setBar(id, fillId, percent, tone) {
const bar = document.getElementById(id);
const fill = document.getElementById(fillId);
bar.className = `metric-bar${tone === "online" ? "" : ` ${tone === "danger" ? "danger" : tone}`}`;
fill.style.width = `${Math.max(0, Math.min(percent, 100))}%`;
}
export function renderStats(state) {
const { system } = state.data;
const cpuTone = toneForPercent(system.cpu.usage_percent);
const ramTone = toneForPercent(system.memory.usage_percent);
const networkTone = "info";
const uptimeTone = system.source.status === "online" ? "online" : "danger";
setSignal("cpu-signal", cpuTone);
document.getElementById("cpu-value").textContent = pct(system.cpu.usage_percent);
document.getElementById("cpu-value").className = `card-value value-${cpuTone}`;
document.getElementById("cpu-cores").textContent = `${system.cpu.cores} CORES`;
document.getElementById("cpu-cores").className = `metric-accent ${cpuTone === "danger" ? "warning" : "online"}`;
document.getElementById("cpu-load").textContent = system.cpu.load_1.toFixed(2);
setBar("cpu-bar", "cpu-bar-fill", system.cpu.usage_percent, cpuTone);
setSignal("ram-signal", ramTone);
document.getElementById("ram-value").textContent = pct(system.memory.usage_percent);
document.getElementById("ram-value").className = `card-value value-${ramTone}`;
document.getElementById("ram-used").textContent = gb(system.memory.used_gb);
document.getElementById("ram-used").className = `metric-accent ${ramTone}`;
document.getElementById("ram-free").textContent = gb(system.memory.available_gb);
document.getElementById("ram-free").className = "metric-accent info";
setBar("ram-bar", "ram-bar-fill", system.memory.usage_percent, ramTone);
setSignal("network-signal", networkTone);
document.getElementById("network-value").textContent = system.network.rx_mbps.toFixed(1);
document.getElementById("network-value").className = "card-value value-info";
document.getElementById("network-tx").textContent = `${system.network.tx_mbps.toFixed(1)} MBPS`;
setBar("network-bar", "network-bar-fill", Math.min(system.network.rx_mbps * 4, 100), networkTone);
const uptimeDays = Math.max(0, Math.floor(system.host.uptime_seconds / 86400));
const uptimeHours = Math.max(0, Math.floor(system.host.uptime_seconds / 3600));
setSignal("uptime-signal", uptimeTone);
document.getElementById("uptime-value").textContent = `${uptimeDays}D`;
document.getElementById("uptime-value").className = `card-value value-${uptimeTone === "danger" ? "danger" : "online"}`;
document.getElementById("uptime-host").textContent = system.source.host_name.toUpperCase();
document.getElementById("uptime-hours").textContent = String(uptimeHours);
setBar("uptime-bar", "uptime-bar-fill", system.source.status === "online" ? 84 : 10, uptimeTone === "danger" ? "danger" : "online");
}
@@ -0,0 +1,54 @@
function gb(value) {
return `${Number(value).toFixed(1)} GB`;
}
function diskTone(status) {
if (status === "critical" || status === "offline") return "danger";
if (status === "warning") return "warning";
return "online";
}
function pillLabel(status) {
if (status === "critical") return "Critical";
if (status === "warning") return "Warning";
if (status === "offline") return "Offline";
return "Stable";
}
export function renderStorage(state) {
const { storage } = state.data;
const rootTone = diskTone(storage.root.status);
const rootPill = document.getElementById("root-storage-pill");
rootPill.className = `status-pill ${rootTone === "danger" ? "offline" : rootTone}`;
rootPill.textContent = pillLabel(storage.root.status);
document.getElementById("root-storage-value").textContent = `${storage.root.usage_percent.toFixed(1)}%`;
document.getElementById("root-storage-value").className = `card-value value-${rootTone === "danger" ? "danger" : rootTone}`;
document.getElementById("root-storage-used").textContent = gb(storage.root.used_gb);
document.getElementById("root-storage-free").textContent = gb(storage.root.free_gb);
const rootBar = document.getElementById("root-storage-bar");
rootBar.className = `metric-bar${rootTone === "online" ? "" : ` ${rootTone === "danger" ? "danger" : rootTone}`}`;
document.getElementById("root-storage-bar-fill").style.width = `${storage.root.usage_percent}%`;
const signal = document.getElementById("disk-matrix-signal");
signal.className = `signal ${storage.summary.overall_status === "degraded" ? "warning" : storage.summary.overall_status === "offline" ? "offline" : "online"}`;
const list = document.getElementById("storage-list");
list.innerHTML = storage.disks
.map((disk) => {
const tone = diskTone(disk.status);
const pillClass = tone === "danger" ? "offline" : tone === "warning" ? "warning" : "online";
const barClass = tone === "online" ? "metric-bar" : `metric-bar ${tone === "danger" ? "danger" : tone}`;
return `
<div class="storage-row">
<strong>${disk.name}</strong>
<span>${disk.mount}</span>
<div class="${barClass}"><span style="width:${Math.max(0, Math.min(disk.usage_percent, 100))}%"></span></div>
<span class="status-pill ${pillClass}">${disk.usage_percent.toFixed(0)}%</span>
</div>
`;
})
.join("");
}
+146
View File
@@ -0,0 +1,146 @@
const DEFAULT_DATA = {
overview: {
generated_at: new Date().toISOString(),
overall_status: "online",
refresh_hint_seconds: 20,
services: { online: 8, degraded: 2, offline: 1, total: 11 },
docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
system: {
cpu_percent: 23,
ram_percent: 61,
root_storage_percent: 49,
network_rx_mbps: 12.4,
network_tx_mbps: 3.1,
uptime_seconds: 864000,
},
home_assistant: {
status: "online",
label: "Home Assistant",
version: "2026.3.4",
response_time_ms: 142,
last_checked: new Date().toISOString(),
},
},
system: {
generated_at: new Date().toISOString(),
source: {
name: "beszel",
status: "online",
host_name: "homelab-01",
agent_name: "beszel-agent",
},
cpu: { usage_percent: 23, cores: 8, load_1: 0.82, load_5: 0.74, load_15: 0.69 },
memory: { used_gb: 19.6, total_gb: 32, available_gb: 12.4, usage_percent: 61 },
network: { primary_interface: "eth0", rx_mbps: 12.4, tx_mbps: 3.1 },
host: { uptime_seconds: 864000, platform: "linux", kernel: "6.8.0" },
},
services: {
generated_at: new Date().toISOString(),
summary: {
overall_status: "degraded",
docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
uptime_kuma: { monitors_up: 8, monitors_down: 1, monitors_paused: 1, total: 10, source_status: "online" },
},
services: [
{
id: "homeassistant",
name: "Home Assistant",
kind: "core",
status: "online",
health: "healthy",
latency_ms: 142,
docker_state: "running",
url: "#",
source: "home_assistant",
last_checked: new Date().toISOString(),
},
{
id: "immich",
name: "Immich",
kind: "service",
status: "degraded",
health: "warning",
latency_ms: 821,
docker_state: "running",
url: "#",
source: "uptime_kuma",
last_checked: new Date().toISOString(),
},
{
id: "gitea",
name: "Gitea",
kind: "service",
status: "online",
health: "healthy",
latency_ms: 98,
docker_state: "running",
url: "#",
source: "uptime_kuma",
last_checked: new Date().toISOString(),
},
{
id: "adguard",
name: "AdGuard",
kind: "service",
status: "offline",
health: "offline",
latency_ms: null,
docker_state: "stopped",
url: "#",
source: "docker",
last_checked: new Date().toISOString(),
},
],
},
storage: {
generated_at: new Date().toISOString(),
summary: { overall_status: "degraded", critical_disks: 0, warning_disks: 1, total_disks: 3 },
root: { name: "rootfs", mount: "/", used_gb: 233.8, total_gb: 480, free_gb: 246.2, usage_percent: 48.7, status: "online" },
disks: [
{ name: "rootfs", mount: "/", used_gb: 233.8, total_gb: 480, free_gb: 246.2, usage_percent: 48.7, status: "online" },
{ name: "data", mount: "/data", used_gb: 712.1, total_gb: 1000, free_gb: 287.9, usage_percent: 71.2, status: "warning" },
{ name: "backup", mount: "/backup", used_gb: 201.4, total_gb: 2000, free_gb: 1798.6, usage_percent: 10.1, status: "online" },
],
},
};
const listeners = new Set();
const state = {
data: structuredClone(DEFAULT_DATA),
isLoading: true,
error: null,
lastRefreshAt: null,
refreshIntervalMs: 20000,
};
export function subscribe(listener) {
listeners.add(listener);
listener(state);
return () => listeners.delete(listener);
}
export function getState() {
return state;
}
export function updateData(payload) {
state.data = payload;
state.isLoading = false;
state.error = null;
state.lastRefreshAt = new Date();
state.refreshIntervalMs = Math.max((payload?.overview?.refresh_hint_seconds ?? 20) * 1000, 15000);
emit();
}
export function setError(error) {
state.isLoading = false;
state.error = error;
emit();
}
function emit() {
for (const listener of listeners) {
listener(state);
}
}
+1
View File
@@ -0,0 +1 @@
"""Homelab dashboard backend package."""
@@ -0,0 +1 @@
"""External system clients."""
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.config import Settings
logger = logging.getLogger(__name__)
class BaseHTTPClient:
def __init__(self, settings: Settings, name: str, base_url: str | None) -> None:
self.settings = settings
self.name = name
self.base_url = str(base_url).rstrip("/") if base_url else None
async def _request_json(
self,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
auth: tuple[str, str] | None = None,
) -> Any | None:
response = await self._request(
method,
path,
headers=headers,
params=params,
auth=auth,
)
if response is None:
return None
try:
return response.json()
except ValueError:
logger.warning("%s returned non-JSON payload for %s", self.name, path)
return None
async def _request_text(
self,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
auth: tuple[str, str] | None = None,
) -> str | None:
response = await self._request(
method,
path,
headers=headers,
params=params,
auth=auth,
)
if response is None:
return None
return response.text
async def _request(
self,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
auth: tuple[str, str] | None = None,
) -> httpx.Response | None:
if not self.base_url:
logger.info("%s client skipped because base URL is not configured", self.name)
return None
url = f"{self.base_url}/{path.lstrip('/')}"
try:
async with httpx.AsyncClient(
timeout=self.settings.request_timeout_seconds,
trust_env=False,
) as client:
response = await client.request(
method,
url,
headers=headers,
params=params,
auth=auth,
)
response.raise_for_status()
return response
except httpx.TimeoutException:
logger.warning("%s request timed out: %s %s", self.name, method, url)
except httpx.HTTPStatusError as exc:
logger.warning(
"%s request failed with status %s for %s %s",
self.name,
exc.response.status_code,
method,
url,
)
except httpx.HTTPError as exc:
logger.warning("%s request error for %s %s: %s", self.name, method, url, exc)
return None
@@ -0,0 +1,417 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import logging
from typing import Any
from app.clients.base import BaseHTTPClient
from app.config import Settings
from app.models.sources import (
BeszelDiskMetric,
BeszelSystemSnapshot,
DockerContainerSummary,
DockerSnapshot,
)
logger = logging.getLogger(__name__)
class BeszelClient(BaseHTTPClient):
"""
Beszel exposes a PocketBase-backed REST API. The exact record schema may
change between Beszel releases, so this client intentionally normalizes
multiple likely field layouts into a stable internal snapshot.
"""
def __init__(self, settings: Settings) -> None:
super().__init__(settings, "beszel", settings.beszel_base_url)
self._admin_jwt: str | None = None
self._admin_jwt_expires_at: datetime | None = None
async def fetch_system_snapshot(self) -> BeszelSystemSnapshot:
snapshot = BeszelSystemSnapshot()
if not self.base_url:
logger.info("beszel skipped: base URL missing")
return snapshot
headers = await self._build_auth_headers()
if headers is None:
logger.warning("beszel skipped: no usable auth method configured")
return snapshot
payload = await self._request_json(
"GET",
"/api/collections/system_stats/records",
headers=headers,
params={"page": 1, "perPage": 1, "sort": "-created"},
)
if not payload:
logger.warning("beszel returned empty payload")
return snapshot
logger.info("beszel raw payload: %s", payload)
items = payload.get("items") if isinstance(payload, dict) else None
if not items:
logger.warning("beszel returned no system_stats records")
return snapshot
record = items[0]
details = await self._fetch_system_details(headers, record)
normalized = self._normalize_snapshot(record, details)
logger.info("beszel normalized snapshot: %s", normalized.model_dump())
return normalized
async def fetch_container_snapshot(self) -> DockerSnapshot:
snapshot = DockerSnapshot()
if not self.base_url:
return snapshot
headers = await self._build_auth_headers()
if headers is None:
return snapshot
payload = await self._request_json(
"GET",
"/api/collections/containers/records",
headers=headers,
params={"page": 1, "perPage": 200, "sort": "-updated"},
)
logger.info("beszel raw containers payload: %s", payload)
if not isinstance(payload, dict):
logger.warning("beszel containers mapping: payload is not a dict")
return snapshot
items = payload.get("items")
if not isinstance(items, list):
logger.warning("beszel containers mapping: items missing or not a list")
return snapshot
if not items:
logger.warning("beszel containers mapping: no container records returned")
return snapshot
containers: list[DockerContainerSummary] = []
running = 0
stopped = 0
unhealthy = 0
for item in items:
if not isinstance(item, dict):
continue
state = self._normalize_container_state(item)
if state == "running":
running += 1
elif state == "unhealthy":
unhealthy += 1
else:
stopped += 1
containers.append(
DockerContainerSummary(
id=str(item.get("id") or ""),
name=str(item.get("name") or item.get("container") or item.get("service") or "unknown"),
state=state,
status_text=str(item.get("status") or item.get("state") or "unknown"),
image=str(item.get("image") or ""),
health=str(item.get("health") or item.get("health_status") or "").lower() or None,
)
)
normalized = DockerSnapshot(
source_status="online",
running=running,
stopped=stopped,
unhealthy=unhealthy,
total=len(containers),
containers=containers,
)
logger.info("beszel normalized containers snapshot: %s", normalized.model_dump())
return normalized
def _normalize_snapshot(self, record: dict[str, Any], details: dict[str, Any] | None) -> BeszelSystemSnapshot:
stats = self._coerce_mapping(record.get("stats") or {})
details = self._coerce_mapping(details or {})
details_payload = self._coerce_mapping(
details.get("details")
or details.get("stats")
or details.get("info")
or details.get("data")
or {}
)
expanded_system = self._coerce_mapping(self._coerce_mapping(record.get("expand")).get("system"))
memory_used_gb = self._as_float(stats.get("m"))
memory_total_gb = self._as_float(stats.get("m"))
if stats.get("mp"):
memory_total_gb = round(memory_used_gb / (self._as_float(stats.get("mp")) / 100), 1) if self._as_float(stats.get("mp")) else memory_used_gb
memory_available_gb = max(round(memory_total_gb - memory_used_gb, 1), 0.0)
network_pair = stats.get("b") if isinstance(stats.get("b"), list) else []
disks = self._normalize_disks(
details_payload.get("disks")
or details_payload.get("disk")
or details_payload.get("mounts")
or details.get("disks")
or details.get("disk")
or details.get("mounts")
or []
)
if not disks:
logger.info("beszel storage unsupported: no disks/mounts in payload")
return BeszelSystemSnapshot(
source_status="online",
host_name=str(
details_payload.get("hostname")
or details_payload.get("host")
or details.get("hostname")
or details.get("host")
or expanded_system.get("name")
or record.get("system_name")
or record.get("name")
or "unknown"
),
agent_name="beszel-agent",
cpu_usage_percent=self._as_float(stats.get("cpu")),
cpu_cores=len(stats.get("cpus")) if isinstance(stats.get("cpus"), list) else 0,
load_1=0.0,
load_5=0.0,
load_15=0.0,
memory_used_gb=memory_used_gb,
memory_total_gb=memory_total_gb,
memory_available_gb=memory_available_gb,
memory_usage_percent=self._as_float(stats.get("mp")),
primary_interface=str(
details_payload.get("primary_interface")
or details_payload.get("interface")
or details.get("primary_interface")
or "primary"
),
network_rx_mbps=self._network_value_to_mbps(network_pair[0] if len(network_pair) > 0 else 0),
network_tx_mbps=self._network_value_to_mbps(network_pair[1] if len(network_pair) > 1 else 0),
uptime_seconds=self._minutes_to_seconds(stats.get("d")),
platform=str(
details_payload.get("platform")
or details_payload.get("os")
or details.get("platform")
or "unknown"
),
kernel=str(details_payload.get("kernel") or details.get("kernel") or "unknown"),
disks=disks,
)
async def _fetch_system_details(
self,
headers: dict[str, str],
stats_record: dict[str, Any],
) -> dict[str, Any] | None:
system_id = stats_record.get("system")
params: dict[str, Any] = {
"page": 1,
"perPage": 100,
"sort": "-created",
}
payload = await self._request_json(
"GET",
"/api/collections/system_details/records",
headers=headers,
params=params,
)
logger.info("beszel raw details payload: %s", payload)
if not isinstance(payload, dict):
logger.warning("beszel system_details mapping: payload is not a dict")
return None
items = payload.get("items")
if isinstance(items, list) and items:
if system_id:
for item in items:
if isinstance(item, dict) and str(item.get("system") or "") == str(system_id):
return item
return items[0]
logger.warning("beszel system_details mapping: no matching detail records returned")
return None
@staticmethod
def _normalize_container_state(item: dict[str, Any]) -> str:
raw = " ".join(
str(item.get(key) or "")
for key in ("status", "state", "health", "health_status")
).lower()
if "unhealthy" in raw or "degraded" in raw:
return "unhealthy"
if any(token in raw for token in ("running", "up", "healthy", "active")):
return "running"
if raw.strip():
return "stopped"
return "unknown"
async def _build_auth_headers(self) -> dict[str, str] | None:
admin_headers = await self._get_admin_auth_headers()
if admin_headers:
return admin_headers
if self.settings.beszel_api_token:
return {"Authorization": self.settings.beszel_api_token}
return None
async def _get_admin_auth_headers(self) -> dict[str, str] | None:
if not self.settings.beszel_admin_email or not self.settings.beszel_admin_password:
return None
now = datetime.now(timezone.utc)
if self._admin_jwt and self._admin_jwt_expires_at and now < self._admin_jwt_expires_at:
return {"Authorization": self._admin_jwt}
auth_payload = await self._request_json(
"POST",
"/api/collections/_superusers/auth-with-password",
headers={"Content-Type": "application/json"},
params=None,
)
if auth_payload is None:
auth_payload = await self._request_json_with_body(
"POST",
"/api/collections/_superusers/auth-with-password",
json_body={
"identity": self.settings.beszel_admin_email,
"password": self.settings.beszel_admin_password,
},
)
if not isinstance(auth_payload, dict):
logger.warning("beszel admin auth failed: no payload")
return None
token = auth_payload.get("token")
if not token:
logger.warning("beszel admin auth failed: token missing")
return None
self._admin_jwt = str(token)
self._admin_jwt_expires_at = now + timedelta(minutes=30)
logger.info("beszel admin auth succeeded")
return {"Authorization": self._admin_jwt}
async def _request_json_with_body(
self,
method: str,
path: str,
*,
json_body: dict[str, Any],
) -> Any | None:
if not self.base_url:
return None
import httpx
url = f"{self.base_url}/{path.lstrip('/')}"
try:
async with httpx.AsyncClient(
timeout=self.settings.request_timeout_seconds,
trust_env=False,
) as client:
response = await client.request(
method,
url,
json=json_body,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
logger.warning("beszel auth request timed out: %s %s", method, url)
except httpx.HTTPStatusError as exc:
logger.warning("beszel auth request failed with status %s for %s %s", exc.response.status_code, method, url)
try:
logger.info("beszel auth error payload: %s", exc.response.json())
except ValueError:
logger.info("beszel auth error text: %s", exc.response.text)
except httpx.HTTPError as exc:
logger.warning("beszel auth request error for %s %s: %s", method, url, exc)
return None
def _normalize_disks(self, raw_disks: Any) -> list[BeszelDiskMetric]:
if isinstance(raw_disks, dict):
raw_disks = [
{"mount": key, **(value if isinstance(value, dict) else {"used": value})}
for key, value in raw_disks.items()
]
disks: list[BeszelDiskMetric] = []
if not isinstance(raw_disks, list):
return disks
for item in raw_disks:
if not isinstance(item, dict):
continue
total_gb = self._bytes_to_gb(item.get("total") or item.get("total_bytes"))
used_gb = self._bytes_to_gb(item.get("used") or item.get("used_bytes"))
free_gb = self._bytes_to_gb(item.get("free") or item.get("free_bytes"))
usage_percent = self._as_float(item.get("usage_percent") or item.get("percent"))
if not total_gb and used_gb and free_gb:
total_gb = round(used_gb + free_gb, 1)
disks.append(
BeszelDiskMetric(
name=str(item.get("name") or item.get("device") or item.get("mount") or "disk"),
mount=str(item.get("mount") or item.get("path") or "/"),
used_gb=used_gb,
total_gb=total_gb,
free_gb=free_gb,
usage_percent=usage_percent,
)
)
return disks
@staticmethod
def _coerce_mapping(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
@staticmethod
def _as_float(value: Any) -> float:
try:
return round(float(value or 0), 1)
except (TypeError, ValueError):
return 0.0
@staticmethod
def _as_int(value: Any) -> int:
try:
return int(float(value or 0))
except (TypeError, ValueError):
return 0
@classmethod
def _bytes_to_gb(cls, value: Any) -> float:
if value in (None, ""):
return 0.0
try:
return round(float(value) / (1024 ** 3), 1)
except (TypeError, ValueError):
return 0.0
@classmethod
def _bytes_per_second_to_mbps(cls, value: Any) -> float:
if value in (None, ""):
return 0.0
try:
return round((float(value) * 8) / 1_000_000, 1)
except (TypeError, ValueError):
return 0.0
@classmethod
def _network_value_to_mbps(cls, value: Any) -> float:
try:
numeric = float(value or 0)
except (TypeError, ValueError):
return 0.0
if numeric <= 0:
return 0.0
return round((numeric * 8) / 1_000_000, 1)
@classmethod
def _minutes_to_seconds(cls, value: Any) -> int:
try:
return int(float(value or 0) * 60)
except (TypeError, ValueError):
return 0
@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
from app.clients.beszel_client import BeszelClient
from app.clients.base import BaseHTTPClient
from app.config import Settings
from app.models.sources import DockerContainerSummary, DockerSnapshot
logger = logging.getLogger(__name__)
class DockerProxyClient(BaseHTTPClient):
def __init__(self, settings: Settings) -> None:
super().__init__(settings, "docker-proxy", settings.docker_proxy_base_url)
self.beszel_client = BeszelClient(settings)
async def fetch_containers(self) -> DockerSnapshot:
snapshot = DockerSnapshot()
payload = await self._request_json("GET", "/containers/json", params={"all": "true"})
if not isinstance(payload, list):
logger.warning("docker proxy returned non-list payload: %s", payload)
fallback = await self.beszel_client.fetch_container_snapshot()
if fallback.source_status == "online":
logger.info("docker proxy fallback to beszel containers succeeded")
return fallback
logger.warning(
"docker integration unavailable: docker proxy unreachable and beszel container fallback returned no usable data"
)
return snapshot
logger.info("docker proxy raw payload count: %s", len(payload))
logger.info("docker proxy raw payload sample: %s", payload[:3])
containers: list[DockerContainerSummary] = []
running = 0
stopped = 0
unhealthy = 0
for item in payload:
if not isinstance(item, dict):
continue
state = self._normalize_state(item)
if state == "running":
running += 1
elif state == "unhealthy":
unhealthy += 1
else:
stopped += 1
containers.append(
DockerContainerSummary(
id=str(item.get("Id") or item.get("ID") or ""),
name=self._normalize_name(item.get("Names")),
state=state,
status_text=str(item.get("Status") or item.get("State") or "unknown"),
image=str(item.get("Image") or ""),
health=self._extract_health(item),
)
)
normalized = DockerSnapshot(
source_status="online",
running=running,
stopped=stopped,
unhealthy=unhealthy,
total=len(containers),
containers=containers,
)
logger.info("docker proxy normalized snapshot: %s", normalized.model_dump())
return normalized
@staticmethod
def _normalize_name(names: object) -> str:
if isinstance(names, list) and names:
return str(names[0]).lstrip("/")
return "unknown"
@classmethod
def _normalize_state(cls, item: dict) -> str:
status_text = str(item.get("Status") or "").lower()
state = str(item.get("State") or "").lower()
health = cls._extract_health(item)
if health == "unhealthy" or "unhealthy" in status_text:
return "unhealthy"
if state == "running":
return "running"
if state:
return "stopped"
return "unknown"
@staticmethod
def _extract_health(item: dict) -> str | None:
if isinstance(item.get("Health"), str):
return str(item["Health"]).lower()
if isinstance(item.get("State"), dict):
health = item["State"].get("Health")
if isinstance(health, dict):
return str(health.get("Status") or "").lower() or None
return None
@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import datetime, timezone
import logging
from time import perf_counter
from app.clients.base import BaseHTTPClient
from app.config import Settings
from app.models.sources import HomeAssistantSnapshot
logger = logging.getLogger(__name__)
class HomeAssistantClient(BaseHTTPClient):
def __init__(self, settings: Settings) -> None:
super().__init__(settings, "home-assistant", settings.home_assistant_base_url)
async def fetch_status(self) -> HomeAssistantSnapshot:
snapshot = HomeAssistantSnapshot()
if not self.base_url or not self.settings.home_assistant_token:
logger.info("home assistant skipped: base URL or token missing")
return snapshot
headers = {
"Authorization": f"Bearer {self.settings.home_assistant_token}",
"Content-Type": "application/json",
}
started_at = perf_counter()
api_info = await self._request_json("GET", "/api/", headers=headers)
if api_info is None:
logger.warning("home assistant base API check failed")
return snapshot
logger.info("home assistant raw /api response: %s", api_info)
elapsed_ms = int((perf_counter() - started_at) * 1000)
config = await self._request_json("GET", "/api/config", headers=headers)
logger.info("home assistant raw /api/config response: %s", config)
version = None
if isinstance(config, dict):
version = config.get("version")
normalized = HomeAssistantSnapshot(
status="online",
version=str(version) if version else None,
response_time_ms=elapsed_ms,
last_checked=datetime.now(timezone.utc),
)
logger.info("home assistant normalized snapshot: %s", normalized.model_dump())
return normalized
@@ -0,0 +1,178 @@
from __future__ import annotations
import logging
import re
import httpx
from app.clients.base import BaseHTTPClient
from app.config import Settings
from app.models.sources import UptimeKumaMonitor, UptimeKumaSnapshot
METRIC_LINE_RE = re.compile(r'^(?P<name>[a-zA-Z_:][a-zA-Z0-9_:]*)\{(?P<labels>[^}]*)\}\s+(?P<value>.+)$')
LABEL_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
logger = logging.getLogger(__name__)
class UptimeKumaClient(BaseHTTPClient):
"""
Reads Uptime Kuma monitor status from the documented /metrics endpoint.
This avoids coupling the backend to Socket.IO login flows, but still relies
on Kuma's internal metrics surface, which may change across releases.
"""
def __init__(self, settings: Settings) -> None:
super().__init__(settings, "uptime-kuma", settings.uptime_kuma_base_url)
async def fetch_monitors(self) -> UptimeKumaSnapshot:
snapshot = UptimeKumaSnapshot()
if not self.base_url:
logger.info("uptime kuma skipped: base URL missing")
return snapshot
raw_metrics = None
if self.settings.uptime_kuma_api_key:
raw_metrics = await self._request_metrics_with_mode(
"basic-api-key",
auth=(self.settings.uptime_kuma_api_key, ""),
)
if (
not raw_metrics
and self.settings.uptime_kuma_username
and self.settings.uptime_kuma_password
):
raw_metrics = await self._request_metrics_with_mode(
"basic-user",
auth=(self.settings.uptime_kuma_username, self.settings.uptime_kuma_password),
)
if raw_metrics is None and not (
self.settings.uptime_kuma_api_key
or (self.settings.uptime_kuma_username and self.settings.uptime_kuma_password)
):
logger.info("uptime kuma skipped: no usable metrics auth configured")
return snapshot
if not raw_metrics:
logger.warning("uptime kuma returned empty metrics payload or metrics auth failed")
return snapshot
logger.info("uptime kuma raw metrics first 40 lines: %s", raw_metrics.splitlines()[:40])
monitors = self._parse_metrics(raw_metrics)
up = sum(1 for monitor in monitors if monitor.status == "online")
down = sum(1 for monitor in monitors if monitor.status == "offline")
paused = sum(1 for monitor in monitors if monitor.status == "degraded")
normalized = UptimeKumaSnapshot(
source_status="online",
monitors_up=up,
monitors_down=down,
monitors_paused=paused,
total=len(monitors),
monitors=sorted(monitors, key=lambda monitor: monitor.name.lower()),
)
logger.info("uptime kuma normalized snapshot: %s", normalized.model_dump())
return normalized
async def _request_metrics_with_mode(
self,
mode: str,
*,
headers: dict[str, str] | None = None,
auth: tuple[str, str] | None = None,
) -> str | None:
if not self.base_url:
return None
url = f"{self.base_url}/metrics"
try:
async with httpx.AsyncClient(
timeout=self.settings.request_timeout_seconds,
trust_env=False,
) as client:
response = await client.request(
"GET",
url,
headers=headers,
auth=auth,
)
if response.status_code == 200 and response.text:
logger.info("uptime kuma metrics auth succeeded via %s", mode)
return response.text
if response.status_code in {401, 403}:
logger.warning("uptime kuma auth failed (401/403)")
else:
logger.info(
"uptime kuma metrics auth failed via %s with status %s",
mode,
response.status_code,
)
return None
except httpx.TimeoutException:
logger.warning("uptime kuma metrics request timed out via %s", mode)
except httpx.HTTPError as exc:
logger.warning("uptime kuma metrics request error via %s: %s", mode, exc)
return None
def _parse_metrics(self, payload: str) -> list[UptimeKumaMonitor]:
status_by_id: dict[str, UptimeKumaMonitor] = {}
for line in payload.splitlines():
parsed = self._parse_metric_line(line)
if parsed is None:
continue
metric_name, labels, raw_value = parsed
monitor_id = labels.get("monitor_id") or labels.get("id") or labels.get("monitor")
monitor_name = labels.get("monitor_name") or labels.get("name")
if not monitor_id or not monitor_name:
continue
monitor = status_by_id.setdefault(
monitor_id,
UptimeKumaMonitor(
id=monitor_id,
name=monitor_name,
status="offline",
monitor_type=labels.get("monitor_type") or labels.get("type"),
),
)
if metric_name == "monitor_status":
status_code = self._as_float(raw_value)
if status_code == 1:
monitor.status = "online"
elif status_code == 3:
monitor.status = "degraded"
else:
monitor.status = "offline"
elif metric_name == "monitor_response_time":
latency = self._as_float(raw_value)
monitor.latency_ms = int(latency) if latency >= 0 else None
return list(status_by_id.values())
@staticmethod
def _parse_metric_line(line: str) -> tuple[str, dict[str, str], str] | None:
if not line or line.startswith("#"):
return None
match = METRIC_LINE_RE.match(line.strip())
if not match:
return None
labels = {
key: value.encode("utf-8").decode("unicode_escape")
for key, value in LABEL_RE.findall(match.group("labels"))
}
return match.group("name"), labels, match.group("value")
@staticmethod
def _as_float(value: str) -> float:
try:
return float(value)
except ValueError:
return -1.0
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import logging
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
BACKEND_ROOT_DIR = Path(__file__).resolve().parents[1]
ENV_FILE_PATH = BACKEND_ROOT_DIR / ".env"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=ENV_FILE_PATH,
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_env: Literal["development", "production", "test"] = "development"
app_host: str = "0.0.0.0"
app_port: int = 8000
app_log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
app_timezone: str = "Europe/Berlin"
app_name: str = "Homelab Dashboard API"
app_version: str = "0.1.0"
app_root_dir: Path = Path(__file__).resolve().parents[2]
cors_allow_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"])
request_timeout_seconds: float = Field(default=5.0, gt=0)
cache_ttl_overview_seconds: int = Field(default=15, ge=1)
cache_ttl_system_seconds: int = Field(default=15, ge=1)
cache_ttl_services_seconds: int = Field(default=15, ge=1)
cache_ttl_storage_seconds: int = Field(default=30, ge=1)
beszel_base_url: HttpUrl | None = None
beszel_api_token: str | None = None
beszel_admin_email: str | None = None
beszel_admin_password: str | None = None
docker_proxy_base_url: HttpUrl | None = None
uptime_kuma_base_url: HttpUrl | None = None
uptime_kuma_api_key: str | None = None
uptime_kuma_username: str | None = None
uptime_kuma_password: str | None = None
home_assistant_base_url: HttpUrl | None = None
home_assistant_token: str | None = None
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
def configure_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import configure_logging, get_settings
from app.routes.overview import router as overview_router
from app.routes.services import router as services_router
from app.routes.storage import router as storage_router
from app.routes.system import router as system_router
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
configure_logging(settings.app_log_level)
logger.info("Starting %s v%s in %s mode", settings.app_name, settings.app_version, settings.app_env)
logger.info(
"Config loaded: HOME_ASSISTANT_BASE_URL=%s HOME_ASSISTANT_TOKEN_SET=%s BESZEL_BASE_URL=%s DOCKER_PROXY_BASE_URL=%s UPTIME_KUMA_BASE_URL=%s",
bool(settings.home_assistant_base_url),
bool(settings.home_assistant_token),
bool(settings.beszel_base_url),
bool(settings.docker_proxy_base_url),
bool(settings.uptime_kuma_base_url),
)
yield
logger.info("Stopping %s", settings.app_name)
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_allow_origins,
allow_credentials=True,
allow_methods=["GET"],
allow_headers=["*"],
)
app.include_router(overview_router)
app.include_router(system_router)
app.include_router(services_router)
app.include_router(storage_router)
assets_dir = settings.app_root_dir / "assets"
dashboard_file = settings.app_root_dir / "dashboard.html"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/health", tags=["health"])
async def health() -> dict[str, str]:
return {
"status": "ok",
"service": settings.app_name,
"version": settings.app_version,
"environment": settings.app_env,
}
@app.get("/", include_in_schema=False)
async def dashboard() -> FileResponse:
return FileResponse(dashboard_file)
@@ -0,0 +1 @@
"""Pydantic models for API and domain data."""
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
SourceStatus = Literal["online", "offline", "unsupported"]
OverallStatus = Literal["online", "degraded", "offline"]
HealthStatus = Literal["healthy", "warning", "offline"]
DiskStatus = Literal["online", "warning", "critical", "offline"]
ServiceKind = Literal["core", "service"]
ServiceSource = Literal["home_assistant", "uptime_kuma", "docker", "manual"]
DockerContainerState = Literal["running", "stopped", "unhealthy", "unknown"]
class APIModel(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
class TimestampedResponse(APIModel):
generated_at: datetime
@@ -0,0 +1,44 @@
from __future__ import annotations
from app.models.common import APIModel, OverallStatus, SourceStatus, TimestampedResponse
class OverviewServicesSummary(APIModel):
online: int
degraded: int
offline: int
total: int
class OverviewDockerSummary(APIModel):
running: int
stopped: int
unhealthy: int
total: int
source_status: SourceStatus
class OverviewSystemSummary(APIModel):
cpu_percent: float
ram_percent: float
root_storage_percent: float
network_rx_mbps: float
network_tx_mbps: float
uptime_seconds: int
class OverviewHomeAssistantSummary(APIModel):
status: SourceStatus
label: str
version: str | None = None
response_time_ms: int | None = None
last_checked: str | None = None
class OverviewResponse(TimestampedResponse):
overall_status: OverallStatus
refresh_hint_seconds: int
services: OverviewServicesSummary
docker: OverviewDockerSummary
system: OverviewSystemSummary
home_assistant: OverviewHomeAssistantSummary
@@ -0,0 +1,52 @@
from __future__ import annotations
from app.models.common import (
APIModel,
DockerContainerState,
HealthStatus,
OverallStatus,
ServiceKind,
ServiceSource,
SourceStatus,
TimestampedResponse,
)
class ServicesDockerSummary(APIModel):
running: int
stopped: int
unhealthy: int
total: int
source_status: SourceStatus
class ServicesUptimeKumaSummary(APIModel):
monitors_up: int
monitors_down: int
monitors_paused: int
total: int
source_status: SourceStatus
class ServicesSummary(APIModel):
overall_status: OverallStatus
docker: ServicesDockerSummary
uptime_kuma: ServicesUptimeKumaSummary
class ServiceItem(APIModel):
id: str
name: str
kind: ServiceKind
status: OverallStatus
health: HealthStatus
latency_ms: int | None = None
docker_state: DockerContainerState
url: str | None = None
source: ServiceSource
last_checked: str | None = None
class ServicesResponse(TimestampedResponse):
summary: ServicesSummary
services: list[ServiceItem]
@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
from pydantic import Field
from app.models.common import APIModel, DockerContainerState, OverallStatus, SourceStatus
class BeszelDiskMetric(APIModel):
name: str
mount: str
used_gb: float
total_gb: float
free_gb: float
usage_percent: float
class BeszelSystemSnapshot(APIModel):
source_name: str = "beszel"
source_status: SourceStatus = "offline"
host_name: str = "unknown"
agent_name: str = "beszel-agent"
cpu_usage_percent: float = 0.0
cpu_cores: int = 0
load_1: float = 0.0
load_5: float = 0.0
load_15: float = 0.0
memory_used_gb: float = 0.0
memory_total_gb: float = 0.0
memory_available_gb: float = 0.0
memory_usage_percent: float = 0.0
primary_interface: str = "unknown"
network_rx_mbps: float = 0.0
network_tx_mbps: float = 0.0
uptime_seconds: int = 0
platform: str = "unknown"
kernel: str = "unknown"
disks: list[BeszelDiskMetric] = Field(default_factory=list)
class DockerContainerSummary(APIModel):
id: str
name: str
state: DockerContainerState
status_text: str
image: str
health: str | None = None
class DockerSnapshot(APIModel):
source_name: str = "docker"
source_status: SourceStatus = "offline"
running: int = 0
stopped: int = 0
unhealthy: int = 0
total: int = 0
containers: list[DockerContainerSummary] = Field(default_factory=list)
class UptimeKumaMonitor(APIModel):
id: str
name: str
status: OverallStatus
latency_ms: int | None = None
monitor_type: str | None = None
class UptimeKumaSnapshot(APIModel):
source_name: str = "uptime_kuma"
source_status: SourceStatus = "offline"
monitors_up: int = 0
monitors_down: int = 0
monitors_paused: int = 0
total: int = 0
monitors: list[UptimeKumaMonitor] = Field(default_factory=list)
class HomeAssistantSnapshot(APIModel):
source_name: str = "home_assistant"
status: SourceStatus = "offline"
label: str = "Home Assistant"
version: str | None = None
response_time_ms: int | None = None
last_checked: datetime | None = None
@@ -0,0 +1,27 @@
from __future__ import annotations
from app.models.common import APIModel, DiskStatus, OverallStatus, SourceStatus, TimestampedResponse
class StorageSummary(APIModel):
overall_status: OverallStatus
source_status: SourceStatus
critical_disks: int
warning_disks: int
total_disks: int
class StorageDisk(APIModel):
name: str
mount: str
used_gb: float
total_gb: float
free_gb: float
usage_percent: float
status: DiskStatus
class StorageResponse(TimestampedResponse):
summary: StorageSummary
root: StorageDisk
disks: list[StorageDisk]
@@ -0,0 +1,45 @@
from __future__ import annotations
from app.models.common import APIModel, SourceStatus, TimestampedResponse
class SystemSource(APIModel):
name: str
status: SourceStatus
host_name: str
agent_name: str
class SystemCPU(APIModel):
usage_percent: float
cores: int
load_1: float
load_5: float
load_15: float
class SystemMemory(APIModel):
used_gb: float
total_gb: float
available_gb: float
usage_percent: float
class SystemNetwork(APIModel):
primary_interface: str
rx_mbps: float
tx_mbps: float
class SystemHost(APIModel):
uptime_seconds: int
platform: str
kernel: str
class SystemResponse(TimestampedResponse):
source: SystemSource
cpu: SystemCPU
memory: SystemMemory
network: SystemNetwork
host: SystemHost
@@ -0,0 +1 @@
"""API route modules."""
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.models.overview import OverviewResponse
from app.services.aggregator import AggregatorService, get_aggregator_service
router = APIRouter(prefix="/api", tags=["overview"])
@router.get("/overview", response_model=OverviewResponse)
async def get_overview(
aggregator: AggregatorService = Depends(get_aggregator_service),
) -> OverviewResponse:
return await aggregator.get_overview()
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.models.services import ServicesResponse
from app.services.aggregator import AggregatorService, get_aggregator_service
router = APIRouter(prefix="/api", tags=["services"])
@router.get("/services", response_model=ServicesResponse)
async def get_services(
aggregator: AggregatorService = Depends(get_aggregator_service),
) -> ServicesResponse:
return await aggregator.get_services()
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.models.storage import StorageResponse
from app.services.aggregator import AggregatorService, get_aggregator_service
router = APIRouter(prefix="/api", tags=["storage"])
@router.get("/storage", response_model=StorageResponse)
async def get_storage(
aggregator: AggregatorService = Depends(get_aggregator_service),
) -> StorageResponse:
return await aggregator.get_storage()
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.models.system import SystemResponse
from app.services.aggregator import AggregatorService, get_aggregator_service
router = APIRouter(prefix="/api", tags=["system"])
@router.get("/system", response_model=SystemResponse)
async def get_system(
aggregator: AggregatorService = Depends(get_aggregator_service),
) -> SystemResponse:
return await aggregator.get_system()
@@ -0,0 +1 @@
"""Application services."""
@@ -0,0 +1,383 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from functools import lru_cache
from typing import Iterable
from app.clients.beszel_client import BeszelClient
from app.clients.docker_proxy_client import DockerProxyClient
from app.clients.home_assistant_client import HomeAssistantClient
from app.clients.uptime_kuma_client import UptimeKumaClient
from app.config import Settings, get_settings
from app.models.common import DiskStatus, HealthStatus, OverallStatus
from app.models.overview import (
OverviewDockerSummary,
OverviewHomeAssistantSummary,
OverviewResponse,
OverviewServicesSummary,
OverviewSystemSummary,
)
from app.models.services import (
ServiceItem,
ServicesDockerSummary,
ServicesResponse,
ServicesSummary,
ServicesUptimeKumaSummary,
)
from app.models.sources import (
BeszelDiskMetric,
BeszelSystemSnapshot,
DockerSnapshot,
HomeAssistantSnapshot,
UptimeKumaMonitor,
UptimeKumaSnapshot,
)
from app.models.storage import StorageDisk, StorageResponse, StorageSummary
from app.models.system import (
SystemCPU,
SystemHost,
SystemMemory,
SystemNetwork,
SystemResponse,
SystemSource,
)
from app.services.cache import TTLCacheService
logger = logging.getLogger(__name__)
class AggregatorService:
def __init__(
self,
settings: Settings,
cache: TTLCacheService,
beszel_client: BeszelClient,
docker_client: DockerProxyClient,
uptime_kuma_client: UptimeKumaClient,
home_assistant_client: HomeAssistantClient,
) -> None:
self.settings = settings
self.cache = cache
self.beszel_client = beszel_client
self.docker_client = docker_client
self.uptime_kuma_client = uptime_kuma_client
self.home_assistant_client = home_assistant_client
async def get_system(self) -> SystemResponse:
return await self.cache.get_or_load(
"system",
self.settings.cache_ttl_system_seconds,
self._build_system,
)
async def get_storage(self) -> StorageResponse:
return await self.cache.get_or_load(
"storage",
self.settings.cache_ttl_storage_seconds,
self._build_storage,
)
async def get_services(self) -> ServicesResponse:
return await self.cache.get_or_load(
"services",
self.settings.cache_ttl_services_seconds,
self._build_services,
)
async def get_overview(self) -> OverviewResponse:
return await self.cache.get_or_load(
"overview",
self.settings.cache_ttl_overview_seconds,
self._build_overview,
)
async def _build_system(self) -> SystemResponse:
snapshot = await self.beszel_client.fetch_system_snapshot()
now = datetime.now(timezone.utc)
return SystemResponse(
generated_at=now,
source=SystemSource(
name=snapshot.source_name,
status=snapshot.source_status,
host_name=snapshot.host_name,
agent_name=snapshot.agent_name,
),
cpu=SystemCPU(
usage_percent=snapshot.cpu_usage_percent,
cores=snapshot.cpu_cores,
load_1=snapshot.load_1,
load_5=snapshot.load_5,
load_15=snapshot.load_15,
),
memory=SystemMemory(
used_gb=snapshot.memory_used_gb,
total_gb=snapshot.memory_total_gb,
available_gb=snapshot.memory_available_gb,
usage_percent=snapshot.memory_usage_percent,
),
network=SystemNetwork(
primary_interface=snapshot.primary_interface,
rx_mbps=snapshot.network_rx_mbps,
tx_mbps=snapshot.network_tx_mbps,
),
host=SystemHost(
uptime_seconds=snapshot.uptime_seconds,
platform=snapshot.platform,
kernel=snapshot.kernel,
),
)
async def _build_storage(self) -> StorageResponse:
snapshot = await self.beszel_client.fetch_system_snapshot()
now = datetime.now(timezone.utc)
disks = [self._map_disk(disk, snapshot.source_status) for disk in snapshot.disks]
storage_source_status = snapshot.source_status
if snapshot.source_status == "online" and not disks:
storage_source_status = "unsupported"
if disks:
root = next((disk for disk in disks if disk.mount == "/"), disks[0])
else:
root = StorageDisk(
name="rootfs",
mount="/",
used_gb=0.0,
total_gb=0.0,
free_gb=0.0,
usage_percent=0.0,
status="offline" if snapshot.source_status == "offline" else "online",
)
critical_count = sum(1 for disk in disks if disk.status == "critical")
warning_count = sum(1 for disk in disks if disk.status == "warning")
overall_status = self._combine_statuses(disk.status for disk in disks) if disks else (
"offline" if snapshot.source_status == "offline" else "online"
)
return StorageResponse(
generated_at=now,
summary=StorageSummary(
overall_status=overall_status,
source_status=storage_source_status,
critical_disks=critical_count,
warning_disks=warning_count,
total_disks=len(disks),
),
root=root,
disks=disks,
)
async def _build_services(self) -> ServicesResponse:
docker_snapshot, kuma_snapshot, ha_snapshot = await asyncio.gather(
self.docker_client.fetch_containers(),
self.uptime_kuma_client.fetch_monitors(),
self.home_assistant_client.fetch_status(),
)
services = self._merge_services(docker_snapshot, kuma_snapshot, ha_snapshot)
overall_status = self._combine_statuses(service.status for service in services)
return ServicesResponse(
generated_at=datetime.now(timezone.utc),
summary=ServicesSummary(
overall_status=overall_status,
docker=ServicesDockerSummary(
running=docker_snapshot.running,
stopped=docker_snapshot.stopped,
unhealthy=docker_snapshot.unhealthy,
total=docker_snapshot.total,
source_status=docker_snapshot.source_status,
),
uptime_kuma=ServicesUptimeKumaSummary(
monitors_up=kuma_snapshot.monitors_up,
monitors_down=kuma_snapshot.monitors_down,
monitors_paused=kuma_snapshot.monitors_paused,
total=kuma_snapshot.total,
source_status=kuma_snapshot.source_status,
),
),
services=services,
)
async def _build_overview(self) -> OverviewResponse:
system, storage, services, ha_snapshot = await asyncio.gather(
self.get_system(),
self.get_storage(),
self.get_services(),
self.home_assistant_client.fetch_status(),
)
ha_service = next((item for item in services.services if item.id == "homeassistant"), None)
online_services = sum(1 for item in services.services if item.status == "online")
degraded_services = sum(1 for item in services.services if item.status == "degraded")
offline_services = sum(1 for item in services.services if item.status == "offline")
return OverviewResponse(
generated_at=datetime.now(timezone.utc),
overall_status=self._combine_statuses(
[services.summary.overall_status, storage.summary.overall_status]
),
refresh_hint_seconds=self.settings.cache_ttl_overview_seconds,
services=OverviewServicesSummary(
online=online_services,
degraded=degraded_services,
offline=offline_services,
total=len(services.services),
),
docker=OverviewDockerSummary(
running=services.summary.docker.running,
stopped=services.summary.docker.stopped,
unhealthy=services.summary.docker.unhealthy,
total=services.summary.docker.total,
source_status=services.summary.docker.source_status,
),
system=OverviewSystemSummary(
cpu_percent=system.cpu.usage_percent,
ram_percent=system.memory.usage_percent,
root_storage_percent=storage.root.usage_percent,
network_rx_mbps=system.network.rx_mbps,
network_tx_mbps=system.network.tx_mbps,
uptime_seconds=system.host.uptime_seconds,
),
home_assistant=OverviewHomeAssistantSummary(
status=ha_snapshot.status,
label="Home Assistant",
version=ha_snapshot.version,
response_time_ms=ha_snapshot.response_time_ms if ha_snapshot.response_time_ms is not None else (ha_service.latency_ms if ha_service else None),
last_checked=ha_snapshot.last_checked.isoformat() if ha_snapshot.last_checked else (ha_service.last_checked if ha_service else None),
),
)
def _merge_services(
self,
docker_snapshot: DockerSnapshot,
kuma_snapshot: UptimeKumaSnapshot,
ha_snapshot: HomeAssistantSnapshot,
) -> list[ServiceItem]:
docker_by_name = {
self._normalize_identifier(container.name): container for container in docker_snapshot.containers
}
kuma_by_name = {
self._normalize_identifier(monitor.name): monitor for monitor in kuma_snapshot.monitors
}
services: list[ServiceItem] = []
merged_names = sorted(set(docker_by_name) | set(kuma_by_name))
for normalized_name in merged_names:
container = docker_by_name.get(normalized_name)
monitor = kuma_by_name.get(normalized_name)
status = self._resolve_service_status(container.state if container else "unknown", monitor)
services.append(
ServiceItem(
id=normalized_name,
name=monitor.name if monitor else container.name,
kind="service",
status=status,
health=self._status_to_health(status),
latency_ms=monitor.latency_ms if monitor else None,
docker_state=container.state if container else "unknown",
url=None,
source="uptime_kuma" if monitor else "docker",
last_checked=datetime.now(timezone.utc).isoformat(),
)
)
ha_status: OverallStatus = "online" if ha_snapshot.status == "online" else "offline"
services.insert(
0,
ServiceItem(
id="homeassistant",
name=ha_snapshot.label,
kind="core",
status=ha_status,
health=self._status_to_health(ha_status),
latency_ms=ha_snapshot.response_time_ms,
docker_state=docker_by_name.get("homeassistant").state if docker_by_name.get("homeassistant") else "unknown",
url=str(self.settings.home_assistant_base_url) if self.settings.home_assistant_base_url else None,
source="home_assistant",
last_checked=ha_snapshot.last_checked.isoformat() if ha_snapshot.last_checked else None,
),
)
return services
@staticmethod
def _normalize_identifier(value: str) -> str:
return "".join(ch.lower() for ch in value if ch.isalnum())
def _resolve_service_status(
self,
docker_state: str,
monitor: UptimeKumaMonitor | None,
) -> OverallStatus:
if monitor:
if monitor.status == "offline":
return "offline"
if monitor.status == "degraded":
return "degraded"
if docker_state == "unhealthy":
return "degraded"
if docker_state == "stopped":
return "offline"
if docker_state == "running":
return "online"
return "offline" if monitor is None else monitor.status
@staticmethod
def _status_to_health(status: OverallStatus) -> HealthStatus:
if status == "online":
return "healthy"
if status == "degraded":
return "warning"
return "offline"
def _map_disk(self, disk: BeszelDiskMetric, source_status: str) -> StorageDisk:
if source_status == "offline":
status: DiskStatus = "offline"
elif disk.usage_percent >= 90:
status = "critical"
elif disk.usage_percent >= 75:
status = "warning"
else:
status = "online"
return StorageDisk(
name=disk.name,
mount=disk.mount,
used_gb=disk.used_gb,
total_gb=disk.total_gb,
free_gb=disk.free_gb,
usage_percent=disk.usage_percent,
status=status,
)
def _combine_statuses(self, statuses: Iterable[str]) -> OverallStatus:
normalized = list(statuses)
if not normalized:
return "offline"
if any(status == "offline" for status in normalized):
return "degraded" if any(status == "online" for status in normalized) else "offline"
if any(status in {"degraded", "warning", "critical"} for status in normalized):
return "degraded"
return "online"
@lru_cache(maxsize=1)
def get_cache_service() -> TTLCacheService:
return TTLCacheService()
@lru_cache(maxsize=1)
def get_aggregator_service() -> AggregatorService:
settings = get_settings()
cache = get_cache_service()
return AggregatorService(
settings=settings,
cache=cache,
beszel_client=BeszelClient(settings),
docker_client=DockerProxyClient(settings),
uptime_kuma_client=UptimeKumaClient(settings),
home_assistant_client=HomeAssistantClient(settings),
)
@@ -0,0 +1,60 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable, Generic, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
@dataclass
class CacheEntry(Generic[T]):
value: T
expires_at: datetime
updated_at: datetime
class TTLCacheService:
def __init__(self) -> None:
self._entries: dict[str, CacheEntry[object]] = {}
self._locks: dict[str, asyncio.Lock] = {}
async def get_or_load(
self,
key: str,
ttl_seconds: int,
loader: Callable[[], Awaitable[T]],
) -> T:
entry = self._entries.get(key)
if entry and not self._is_expired(entry):
return entry.value # type: ignore[return-value]
lock = self._locks.setdefault(key, asyncio.Lock())
async with lock:
entry = self._entries.get(key)
if entry and not self._is_expired(entry):
return entry.value # type: ignore[return-value]
try:
value = await loader()
now = datetime.now(timezone.utc)
self._entries[key] = CacheEntry(
value=value,
expires_at=now + timedelta(seconds=ttl_seconds),
updated_at=now,
)
return value
except Exception as exc:
if entry:
logger.warning("Cache loader failed for %s, serving stale data: %s", key, exc)
return entry.value # type: ignore[return-value]
raise
@staticmethod
def _is_expired(entry: CacheEntry[object]) -> bool:
return datetime.now(timezone.utc) >= entry.expires_at
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydantic-settings==2.10.1
httpx==0.28.1
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
services:
dashboard:
build:
context: .
dockerfile: Dockerfile
container_name: kallilab-dashboard
restart: unless-stopped
environment:
APP_ENV: production
APP_HOST: 0.0.0.0
APP_PORT: 8000
APP_LOG_LEVEL: INFO
APP_TIMEZONE: Europe/Berlin
APP_NAME: Homelab Dashboard API
APP_VERSION: 0.1.0
CORS_ALLOW_ORIGINS: '["https://dashboard.kaleschke.info"]'
REQUEST_TIMEOUT_SECONDS: 5.0
CACHE_TTL_OVERVIEW_SECONDS: 15
CACHE_TTL_SYSTEM_SECONDS: 15
CACHE_TTL_SERVICES_SECONDS: 15
CACHE_TTL_STORAGE_SECONDS: 30
BESZEL_BASE_URL: http://beszel:8090
BESZEL_ADMIN_EMAIL: ${BESZEL_ADMIN_EMAIL}
BESZEL_ADMIN_PASSWORD: ${BESZEL_ADMIN_PASSWORD}
UPTIME_KUMA_BASE_URL: http://uptime-kuma:3001
UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME}
UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD}
HOME_ASSISTANT_BASE_URL: ${HOME_ASSISTANT_BASE_URL}
HOME_ASSISTANT_TOKEN: ${HOME_ASSISTANT_TOKEN}
networks:
- frontend_net
security_opt:
- no-new-privileges:true
labels:
- traefik.enable=true
- traefik.docker.network=frontend_net
- traefik.http.routers.dashboard.rule=Host(`dashboard.kaleschke.info`)
- traefik.http.routers.dashboard.entrypoints=websecure
- traefik.http.routers.dashboard.tls=true
- traefik.http.routers.dashboard.tls.certresolver=le
- traefik.http.routers.dashboard.middlewares=authelia@file,secure-headers@file
- traefik.http.services.dashboard.loadbalancer.server.port=8000
networks:
frontend_net:
external: true