dashboard

entfernt dashboard
This commit is contained in:
2026-04-15 16:09:31 +02:00
parent bbdf2ffb60
commit 0f95e61c6f
56 changed files with 5 additions and 3940 deletions
+2 -2
View File
@@ -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
+1
View File
@@ -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`.
-4
View File
@@ -1,4 +0,0 @@
backend/.env
backend/app/__pycache__
backend/app/**/*.pyc
assets/.DS_Store
-20
View File
@@ -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=
-19
View File
@@ -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"]
-22
View File
@@ -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 };
}
-40
View File
@@ -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);
@@ -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`;
}
@@ -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())}`;
}
}
@@ -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)" : "";
}
}
@@ -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);
}
@@ -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 = `<div class="scrutiny-offline">\u2014 ${online ? "no disks" : "offline"}</div>`;
return;
}
list.innerHTML = `<div class="scrutiny-strip">${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 `<span class="scrutiny-chip ${cls}"><strong>${token}</strong>${name}</span>`;
}).join("")}</div>`;
}
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}`;
}
@@ -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 }) => `
<a class="quick-tile" href="${url}" target="_blank" rel="noopener noreferrer">
<span class="quick-tile-icon">${icon}</span>
<span class="quick-tile-label">${label}</span>
</a>
`).join("");
}
@@ -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" : "");
}
@@ -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");
}
@@ -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 `<span class="storage-chip" style="color:${pickColor(pct)}">${disk.name || disk.mount} ${pct.toFixed(0)}%</span>`;
}).join("")
: '<span class="storage-chip">No disk data</span>';
grid.innerHTML = `
<div class="card service-card storage-card storage-primary">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-storage"><span class="icon-glyph glyph-storage"></span></span>
<span class="service-name">ROOT STORAGE</span>
</div>
<span class="status-pill ${statusPill(root?.status)}">${(root?.status || "stable").toUpperCase()}</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num ${rootTone}">${root ? `${rootPct.toFixed(1)}%` : "\u2014"}</div><div class="stat-label">Usage</div></div>
<div class="stat-block"><div class="stat-num dim">${root ? fmtNum(root.used_gb) : "\u2014"}</div><div class="stat-label">Used GB</div></div>
<div class="stat-block"><div class="stat-num dim">${root ? fmtNum(root.free_gb) : "\u2014"}</div><div class="stat-label">Free GB</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>${root?.mount || "/"}</span><span>${root ? `${rootPct.toFixed(1)}%` : "0%"}</span></div>
<div class="progress-bar"><div class="progress-fill ${rootTone}" style="width:${Math.min(100, rootPct)}%"></div></div>
</div>
</div>
<div class="card service-card storage-card storage-matrix-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-matrix"><span class="icon-glyph glyph-matrix"></span></span>
<span class="service-name">DISK MATRIX</span>
</div>
<span class="status-pill ${matrixPill}">${disks.length}</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num">${disks.length || "\u2014"}</div><div class="stat-label">Volumes</div></div>
<div class="stat-block"><div class="stat-num warn">${warningCount}</div><div class="stat-label">Warning</div></div>
<div class="stat-block"><div class="stat-num danger">${criticalCount}</div><div class="stat-label">Critical</div></div>
<div class="stat-block"><div class="stat-num ${pickTone(highest?.usage_percent ?? 0)}">${highest ? `${(highest.usage_percent ?? 0).toFixed(0)}%` : "\u2014"}</div><div class="stat-label">Peak</div></div>
</div>
<div class="service-footer"><span>Mounted Volumes</span><div class="storage-strip">${strip}</div></div>
</div>
`;
}
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";
}
@@ -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 =>
`<span class="uk-down-name">▼ ${m.name}</span>`
).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 = '<span class="widget-offline-msg">—</span>';
} 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 =>
`<span class="hb-seg ${b ? "hb-up" : "hb-down"}"></span>`
).join("");
return `
<div class="uk-monitor-row">
<span class="uk-monitor-name">${m.name}</span>
<span class="uk-bar">${segments}</span>
</div>`;
}).join("");
}
}
}
-60
View File
@@ -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));
}
-1
View File
@@ -1 +0,0 @@
"""Homelab dashboard backend package."""
@@ -1 +0,0 @@
"""External system clients."""
@@ -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
@@ -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,
)
-111
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
)
@@ -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)
@@ -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<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 /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
-79
View File
@@ -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",
)
-93
View File
@@ -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)
@@ -1 +0,0 @@
"""Pydantic models for API and domain data."""
@@ -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
@@ -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
@@ -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]
@@ -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
@@ -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]
@@ -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
@@ -1 +0,0 @@
"""API route modules."""
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -1 +0,0 @@
"""Application services."""
@@ -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),
)
@@ -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
-4
View File
@@ -1,4 +0,0 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydantic-settings==2.10.1
httpx==0.28.1
-953
View File
@@ -1,953 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KalliLab Control Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Share+Tech+Mono&family=Exo+2:wght@300;400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #060b09;
--bg2: #0a1210;
--card: rgba(8, 18, 15, 0.88);
--card-border: rgba(0, 220, 140, 0.18);
--card-hover: rgba(0, 220, 140, 0.28);
--teal: #00dc8c;
--teal-dim: #009e65;
--teal-bright: #00ffaa;
--teal-glow: rgba(0, 220, 140, 0.4);
--text: #b8d4cc;
--text-dim: #5a8a7a;
--text-bright: #d8f0e8;
--clr-warn: #ff4466;
--red: #ff4466;
--yellow: #ffcc44;
--blue: #44aaff;
--graph: #00cc88;
--font-display: 'Orbitron', monospace;
--font-mono: 'Share Tech Mono', monospace;
--font-body: 'Exo 2', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 220, 140, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 220, 140, 0.025) 1px, transparent 1px);
background-size: 40px 40px;
z-index: 0;
pointer-events: none;
}
@keyframes scanline {
0% { top: -5%; }
100% { top: 105%; }
}
.scanline {
position: fixed;
left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0,220,140,0.06), rgba(0,220,140,0.10), rgba(0,220,140,0.06), transparent);
z-index: 1;
animation: scanline 8s linear infinite;
pointer-events: none;
}
.wrapper {
position: relative;
z-index: 2;
max-width: 1340px;
margin: 0 auto;
padding: 0 10px 32px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0 8px;
border-bottom: 1px solid var(--card-border);
margin-bottom: 10px;
gap: 12px;
}
.header-logo { display: flex; align-items: center; gap: 10px; }
.logo-text {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--teal-bright);
text-shadow: 0 0 20px var(--teal-glow);
letter-spacing: 2px;
}
.logo-sub {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 2px;
text-transform: uppercase;
}
.header-center { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.overall-status { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--teal-dim); }
.status-indicator {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-display); font-size: 11px;
color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow);
}
.status-dot-main { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 8px var(--teal-glow); }
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.clock {
font-family: var(--font-display); font-size: 24px; font-weight: 700;
color: var(--teal-bright); text-shadow: 0 0 20px var(--teal-glow), 0 0 40px rgba(0,220,140,0.2);
letter-spacing: 2px;
}
.date-str { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); text-align: right; }
#last-updated { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: right; }
.section-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
font-family: var(--font-display); font-size: 8px; font-weight: 600;
letter-spacing: 3px; text-transform: uppercase; color: var(--teal-dim);
}
.section-header::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--card-border), transparent); }
.widget-row { display: grid; gap: 8px; margin-bottom: 8px; }
.row-5 { grid-template-columns: repeat(5, 1fr); }
.row-4 { grid-template-columns: repeat(4, 1fr); }
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-2 { grid-template-columns: repeat(2, 1fr); }
.row-2-1 { grid-template-columns: 2fr 1fr; }
.row-1-2 { grid-template-columns: 1fr 2fr; }
.row-3-2 { grid-template-columns: 3fr 2fr; }
.card {
background: rgba(6, 14, 11, 0.78);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
transition: border-color 0.2s, box-shadow 0.2s;
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
}
.card:hover { border-color: rgba(0,220,140,0.32); box-shadow: 0 0 16px rgba(0,220,140,0.07), inset 0 0 20px rgba(0,220,140,0.02); }
.card-title {
font-family: var(--font-display); font-size: 8px; font-weight: 600;
letter-spacing: 2px; text-transform: uppercase; color: var(--teal-dim);
margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; gap: 6px;
}
.card-title-left { display: flex; align-items: center; gap: 6px; }
.card-title .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); flex-shrink: 0; }
.stats-grid { display: flex; justify-content: space-around; gap: 6px; flex-wrap: wrap; }
.stat-block { text-align: center; min-width: 40px; }
.stat-num { font-family: var(--font-display); font-size: 17px; font-weight: 700; color: var(--teal-bright); text-shadow: 0 0 10px var(--teal-glow); line-height: 1.1; }
.stat-num.dim { color: var(--teal-dim); text-shadow: none; font-size: 14px; }
.stat-num.warn { color: var(--yellow); text-shadow: 0 0 10px rgba(255,204,68,0.4); }
.stat-num.danger { color: var(--red); text-shadow: 0 0 10px rgba(255,68,102,0.4); }
.stat-num.blue { color: var(--blue); text-shadow: 0 0 10px rgba(68,170,255,0.4); }
.stat-label { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; margin-top: 1px; }
.status-pill { font-family: var(--font-mono); font-size: 7px; letter-spacing: 1px; padding: 1px 5px; border-radius: 3px; font-weight: 700; }
.pill-online { background: rgba(0,220,140,0.12); color: var(--teal); border: 1px solid rgba(0,220,140,0.3); }
.pill-offline { background: rgba(255,68,102,0.1); color: var(--red); border: 1px solid rgba(255,68,102,0.25); }
.pill-degraded { background: rgba(255,204,68,0.1); color: var(--yellow); border: 1px solid rgba(255,204,68,0.25); }
.progress-wrap { margin-top: 5px; }
.progress-label { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); margin-bottom: 2px; }
.progress-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); transition: width 1s ease; }
.progress-fill.warn { background: linear-gradient(90deg, #cc8800, var(--yellow)); }
.progress-fill.danger { background: linear-gradient(90deg, #cc2244, var(--red)); }
.service-header { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
.service-icon { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.service-name { font-family: var(--font-display); font-size: 9px; font-weight: 600; color: var(--text-bright); letter-spacing: 1px; flex: 1; }
.service-version { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); }
.sys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 10px; font-family: var(--font-mono); font-size: 10px; }
.sys-row { display: flex; justify-content: space-between; padding: 1px 0; }
.sys-key { color: var(--text-dim); }
.sys-val { color: var(--teal); }
.disk-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3px; }
.disk-name { font-family: var(--font-display); font-size: 9px; color: var(--text-bright); letter-spacing: 1px; }
.disk-usage { font-family: var(--font-mono); font-size: 9px; color: var(--teal); }
.disk-sub { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); margin-bottom: 4px; }
.scrutiny-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-family: var(--font-mono); font-size: 9px; border-bottom: 1px solid rgba(0,220,140,0.05); }
.scrutiny-row:last-child { border-bottom: none; }
.disk-icon { font-size: 10px; font-weight: bold; width: 12px; text-align: center; }
.disk-ok { color: var(--teal); }
.disk-fail { color: var(--red); }
.disk-unk { color: var(--text-dim); }
.disk-name-col { flex: 1; color: var(--text-bright); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disk-model { color: var(--text-dim); font-size: 8px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disk-temp { color: var(--teal-dim); font-size: 8px; white-space: nowrap; }
.scrutiny-offline { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); padding: 4px 0; }
.uk-monitor-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.uk-monitor-name { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); min-width: 70px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.uk-bar { display: flex; gap: 1px; flex: 1; }
.hb-seg { height: 7px; flex: 1; border-radius: 1px; }
.hb-up { background: var(--teal); box-shadow: 0 0 3px var(--teal-glow); opacity: 0.85; }
.hb-down { background: var(--red); box-shadow: 0 0 3px rgba(255,68,102,0.4); opacity: 0.85; }
.uk-down-name { display: block; font-family: var(--font-mono); font-size: 8px; color: var(--red); padding: 1px 0; }
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-ok { background: var(--teal); box-shadow: 0 0 5px var(--teal-glow); }
.dot-err { background: var(--red); box-shadow: 0 0 5px rgba(255,68,102,0.4); }
.dot-unk { background: var(--text-dim); }
.adguard-bar-wrap { margin-top: 5px; }
.adguard-bar { height: 3px; background: rgba(0,220,140,0.1); border-radius: 2px; overflow: hidden; position: relative; }
.adguard-bar-fill { height: 100%; background: linear-gradient(90deg, var(--teal-dim), var(--teal-bright)); border-radius: 2px; box-shadow: 0 0 5px var(--teal-glow); width: 0%; transition: width 1s ease; }
.net-health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#quick-access-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
.quick-tile { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 6px 7px; background: rgba(6, 14, 11, 0.72); border: 1px solid var(--card-border); border-radius: 7px; cursor: pointer; transition: all 0.18s; text-decoration: none; color: var(--text); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
.quick-tile:hover { border-color: rgba(0,220,140,0.4); background: rgba(0,220,140,0.05); transform: translateY(-2px); box-shadow: 0 4px 18px rgba(0,220,140,0.10); }
.quick-tile-icon { font-size: 18px; line-height: 1; }
.quick-tile-label { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); text-align: center; line-height: 1.2; }
.quick-tile:hover .quick-tile-label { color: var(--teal); }
.docker-row { display: flex; gap: 6px; font-family: var(--font-mono); font-size: 9px; margin-top: 4px; flex-wrap: wrap; }
.docker-chip { padding: 2px 6px; border-radius: 3px; background: rgba(0,220,140,0.07); border: 1px solid rgba(0,220,140,0.15); color: var(--teal-dim); }
.docker-chip.running { color: var(--teal); border-color: rgba(0,220,140,0.3); }
.docker-chip.stopped { color: var(--yellow); border-color: rgba(255,204,68,0.3); background: rgba(255,204,68,0.06); }
.docker-chip.unhealthy { color: var(--red); border-color: rgba(255,68,102,0.3); background: rgba(255,68,102,0.06); }
@media (max-width: 1100px) {
.row-5 { grid-template-columns: repeat(3, 1fr); }
.row-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 780px) {
.row-5, .row-4, .row-3 { grid-template-columns: repeat(2, 1fr); }
.row-2, .row-2-1, .row-1-2, .row-3-2 { grid-template-columns: 1fr; }
.net-health-grid { grid-template-columns: 1fr; }
#quick-access-grid { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); }
}
/* Density / reference lock overrides */
.wrapper { max-width: 1640px; padding: 8px 12px 20px; }
.header { padding: 10px 0 10px; margin-bottom: 8px; gap: 16px; }
.logo-text { font-size: 18px; letter-spacing: 2px; }
.logo-sub, .overall-status, .date-str, #last-updated { font-size: 10px; }
.clock { font-size: 46px; }
.section-header { margin-bottom: 5px; font-size: 9px; letter-spacing: 3px; }
.widget-row { gap: 6px; margin-bottom: 6px; }
.row-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.card {
max-height: 120px;
min-height: 118px;
height: 118px;
padding: 8px 10px 8px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(7, 17, 14, 0.84), rgba(5, 11, 9, 0.72));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 28px rgba(0,0,0,0.22), 0 0 18px rgba(0,220,140,0.05);
overflow: hidden;
}
.card-title { margin-bottom: 6px; min-height: 26px; }
.card-title-left { gap: 7px; }
.card-title .dot { width: 6px; height: 6px; }
.stats-grid {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
align-items: end;
justify-content: stretch;
gap: 10px;
flex-wrap: nowrap;
}
.stat-block {
min-width: 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.stat-num {
font-size: 22px;
line-height: 0.95;
color: var(--teal-bright);
text-shadow: 0 0 12px var(--teal-glow);
}
.stat-num.dim { font-size: 18px; color: var(--text-bright); }
.stat-label { font-size: 8px; margin-top: 2px; }
#cpu-percent, #ram-percent, #net-rx, #uptime-days, #docker-running { font-size: 30px; }
.service-icon {
width: 28px;
height: 28px;
border-radius: 8px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-color, var(--teal-bright));
background:
radial-gradient(circle at 30% 30%, rgba(255,255,255,0.16), transparent 45%),
linear-gradient(180deg, rgba(16, 36, 29, 0.96), rgba(7, 14, 11, 0.92));
border: 1px solid rgba(255,255,255,0.06);
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.02),
0 0 16px rgba(0,220,140,0.08);
}
.service-icon::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0,0,0,0.18);
pointer-events: none;
}
.icon-glyph {
width: 15px;
height: 15px;
display: block;
position: relative;
color: inherit;
opacity: 0.96;
}
.icon-cpu { --icon-color: #82ffbf; }
.icon-memory { --icon-color: #98ffbf; }
.icon-network { --icon-color: #7dc8ff; }
.icon-host { --icon-color: #95ffbf; }
.icon-docker { --icon-color: #81ffc8; }
.icon-storage { --icon-color: #7effb6; }
.icon-matrix { --icon-color: #7fc9ff; }
.icon-scrutiny { --icon-color: #7effb0; }
.icon-ha { --icon-color: #8ea6ff; }
.icon-kuma { --icon-color: #89ffaf; }
.icon-immich { --icon-color: #ffd84f; }
.icon-backrest { --icon-color: #74d7ff; }
.icon-adguard { --icon-color: #66f0ba; }
.icon-services { --icon-color: #8affbe; }
.glyph-cpu {
border: 1.6px solid currentColor;
border-radius: 3px;
}
.glyph-cpu::before {
content: "";
position: absolute;
inset: 3px;
border: 1.4px solid currentColor;
border-radius: 2px;
opacity: 0.92;
}
.glyph-cpu::after {
content: "";
position: absolute;
inset: -3px;
background:
linear-gradient(currentColor,currentColor) 2px 1px / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 7px 1px / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 12px 1px / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 2px calc(100% - 1px) / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 7px calc(100% - 1px) / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 12px calc(100% - 1px) / 1px 3px no-repeat,
linear-gradient(currentColor,currentColor) 1px 2px / 3px 1px no-repeat,
linear-gradient(currentColor,currentColor) 1px 7px / 3px 1px no-repeat,
linear-gradient(currentColor,currentColor) calc(100% - 1px) 2px / 3px 1px no-repeat,
linear-gradient(currentColor,currentColor) calc(100% - 1px) 7px / 3px 1px no-repeat;
opacity: 0.78;
}
.glyph-memory {
background:
linear-gradient(currentColor,currentColor) 2px 9px / 2px 4px no-repeat,
linear-gradient(currentColor,currentColor) 6px 6px / 2px 7px no-repeat,
linear-gradient(currentColor,currentColor) 10px 3px / 2px 10px no-repeat;
}
.glyph-memory::before {
content: "";
position: absolute;
inset: 1px;
border: 1.4px solid rgba(152,255,191,0.34);
border-radius: 3px;
}
.glyph-network {
background:
radial-gradient(circle at 2px 11px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 13px 11px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 7.5px 3px, currentColor 0 2px, transparent 2.4px),
linear-gradient(currentColor,currentColor) 7px 4px / 1.4px 7px no-repeat,
linear-gradient(32deg, transparent 44%, currentColor 45% 55%, transparent 56%) 2px 5px / 10px 7px no-repeat,
linear-gradient(-32deg, transparent 44%, currentColor 45% 55%, transparent 56%) 4px 5px / 10px 7px no-repeat;
}
.glyph-host {
border: 1.6px solid currentColor;
border-radius: 3px;
}
.glyph-host::before {
content: "";
position: absolute;
left: 3px;
right: 3px;
bottom: -2px;
height: 1.6px;
background: currentColor;
}
.glyph-host::after {
content: "";
position: absolute;
left: 5px;
right: 5px;
bottom: -5px;
height: 1.6px;
background: currentColor;
border-radius: 999px;
}
.glyph-docker {
background:
linear-gradient(currentColor,currentColor) 1px 3px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 6px 3px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 11px 3px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 6px 8px / 4px 4px no-repeat,
linear-gradient(currentColor,currentColor) 1px 12px / 14px 1.5px no-repeat;
border-radius: 3px;
}
.glyph-storage {
background:
linear-gradient(currentColor,currentColor) 1px 5px / 13px 1.6px no-repeat,
linear-gradient(currentColor,currentColor) 1px 8px / 13px 1.6px no-repeat,
linear-gradient(currentColor,currentColor) 1px 11px / 13px 1.6px no-repeat;
}
.glyph-storage::before {
content: "";
position: absolute;
inset: 1px 1px 2px;
border: 1.4px solid rgba(126,255,182,0.36);
border-radius: 4px;
}
.glyph-matrix {
background:
linear-gradient(currentColor,currentColor) 1px 1px / 5px 5px no-repeat,
linear-gradient(currentColor,currentColor) 9px 1px / 5px 5px no-repeat,
linear-gradient(currentColor,currentColor) 1px 9px / 5px 5px no-repeat,
linear-gradient(currentColor,currentColor) 9px 9px / 5px 5px no-repeat;
opacity: 0.95;
}
.glyph-scrutiny {
border: 1.6px solid currentColor;
border-radius: 50%;
}
.glyph-scrutiny::before {
content: "";
position: absolute;
inset: 3px;
border: 1.4px solid currentColor;
border-radius: 50%;
}
.glyph-scrutiny::after {
content: "";
position: absolute;
left: 7px;
top: 1px;
width: 1.4px;
height: 13px;
background: currentColor;
box-shadow: -6px 6px 0 -5px currentColor, 6px 6px 0 -5px currentColor;
opacity: 0.9;
}
.glyph-home {
background:
linear-gradient(-35deg, transparent 45%, currentColor 46% 56%, transparent 57%) 0 1px / 8px 7px no-repeat,
linear-gradient(35deg, transparent 45%, currentColor 46% 56%, transparent 57%) 7px 1px / 8px 7px no-repeat,
linear-gradient(currentColor,currentColor) 3px 7px / 9px 6px no-repeat;
}
.glyph-kuma {
background:
linear-gradient(currentColor,currentColor) 1px 8px / 3px 1.5px no-repeat,
linear-gradient(55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 3px 6px / 4px 5px no-repeat,
linear-gradient(-55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 6px 4px / 4px 7px no-repeat,
linear-gradient(55deg, transparent 43%, currentColor 44% 56%, transparent 57%) 9px 6px / 4px 5px no-repeat,
linear-gradient(currentColor,currentColor) 12px 8px / 3px 1.5px no-repeat;
}
.glyph-image {
border: 1.5px solid currentColor;
border-radius: 3px;
}
.glyph-image::before {
content: "";
position: absolute;
left: 2px;
right: 2px;
bottom: 2px;
height: 5px;
background:
linear-gradient(140deg, transparent 35%, currentColor 36% 48%, transparent 49%) 0 0 / 8px 5px no-repeat,
linear-gradient(45deg, transparent 32%, currentColor 33% 45%, transparent 46%) 5px 0 / 8px 5px no-repeat;
}
.glyph-image::after {
content: "";
position: absolute;
right: 2px;
top: 2px;
width: 3px;
height: 3px;
border-radius: 50%;
background: currentColor;
}
.glyph-backrest {
border: 1.5px solid currentColor;
border-radius: 3px;
}
.glyph-backrest::before {
content: "";
position: absolute;
left: 2px;
right: 2px;
top: 3px;
height: 2px;
background: currentColor;
box-shadow: 0 4px 0 currentColor;
}
.glyph-backrest::after {
content: "";
position: absolute;
left: 5px;
right: 5px;
bottom: -2px;
height: 1.5px;
background: currentColor;
}
.glyph-shield {
background:
linear-gradient(currentColor,currentColor) 7px 2px / 1.8px 9px no-repeat;
clip-path: polygon(50% 0%, 88% 16%, 88% 50%, 50% 100%, 12% 50%, 12% 16%);
background-color: transparent;
border: 1.5px solid currentColor;
}
.glyph-services {
background:
radial-gradient(circle at 2px 7px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 13px 2px, currentColor 0 2px, transparent 2.4px),
radial-gradient(circle at 13px 12px, currentColor 0 2px, transparent 2.4px),
linear-gradient(currentColor,currentColor) 4px 6px / 7px 1.4px no-repeat,
linear-gradient(currentColor,currentColor) 10px 4px / 1.4px 6px no-repeat;
}
.service-name { font-size: 10px; letter-spacing: 1.4px; }
.status-pill {
width: 10px;
height: 10px;
min-width: 10px;
border-radius: 999px;
padding: 0;
font-size: 0;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 12px rgba(0,0,0,0.18);
}
.pill-online { box-shadow: 0 0 10px rgba(0,220,140,0.32); }
.pill-degraded { box-shadow: 0 0 10px rgba(255,204,68,0.28); }
.pill-offline { box-shadow: 0 0 10px rgba(255,68,102,0.28); }
.progress-wrap {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.progress-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.progress-bar { height: 4px; border-radius: 999px; background: rgba(0,220,140,0.08); }
.mini-graph {
display: flex;
align-items: end;
gap: 3px;
height: 16px;
}
.mini-bar {
flex: 1;
min-width: 0;
border-radius: 2px 2px 0 0;
background: linear-gradient(180deg, rgba(127,255,199,0.9), rgba(0,220,140,0.22));
box-shadow: 0 0 8px rgba(0,220,140,0.12);
}
.mini-bar.warn { background: linear-gradient(180deg, rgba(255,204,68,0.95), rgba(255,204,68,0.24)); }
.mini-bar.danger { background: linear-gradient(180deg, rgba(255,68,102,0.95), rgba(255,68,102,0.24)); }
.mini-bar.blue { background: linear-gradient(180deg, rgba(68,170,255,0.95), rgba(68,170,255,0.24)); }
.services-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 6px;
}
.service-card { display: flex; flex-direction: column; justify-content: space-between; }
.service-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
}
.micro-strip { display: flex; gap: 3px; min-height: 8px; align-items: center; }
.micro-seg { width: 7px; height: 7px; border-radius: 999px; background: rgba(255,255,255,0.1); }
.micro-seg.up { background: var(--teal); box-shadow: 0 0 8px rgba(0,220,140,0.22); }
.micro-seg.down { background: var(--red); box-shadow: 0 0 8px rgba(255,68,102,0.22); }
.micro-seg.warn { background: var(--yellow); box-shadow: 0 0 8px rgba(255,204,68,0.22); }
#quick-access-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.quick-tile {
min-height: 74px;
padding: 8px 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 9px;
text-align: left;
}
.quick-tile-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 11px;
font-family: var(--font-display);
background: linear-gradient(135deg, var(--teal-bright), var(--teal));
color: #04110d;
}
.quick-tile-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.quick-tile-label { font-size: 9px; color: var(--text-bright); }
.quick-tile-meta {
font-family: var(--font-mono);
font-size: 7px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.quick-tile-icon-ha { background: linear-gradient(135deg, #6cb8ff, #9e8dff); }
.quick-tile-icon-komodo { background: linear-gradient(135deg, #00e2b3, #68c7ff); }
.quick-tile-icon-kuma { background: linear-gradient(135deg, #00d98a, #7fffc7); }
.quick-tile-icon-paperless { background: linear-gradient(135deg, #89ffdc, #46cfa0); }
.quick-tile-icon-mealie { background: linear-gradient(135deg, #7ec2ff, #f6ff8c); }
.quick-tile-icon-immich { background: linear-gradient(135deg, #ffd15c, #ff9d4d); }
.quick-tile-icon-gitea { background: linear-gradient(135deg, #9cff87, #3cd675); }
.quick-tile-icon-code { background: linear-gradient(135deg, #5cc4ff, #5f92ff); }
.quick-tile-icon-files { background: linear-gradient(135deg, #6ec8ff, #9be1ff); }
.quick-tile-icon-backrest { background: linear-gradient(135deg, #8bb4ff, #6fd8ff); }
.quick-tile-icon-vault { background: linear-gradient(135deg, #ffe173, #ffaa5c); }
.quick-tile-icon-adguard { background: linear-gradient(135deg, #57ffaa, #57d8ff); }
.quick-tile-icon-traefik { background: linear-gradient(135deg, #8f9fff, #6cc4ff); }
.quick-tile-icon-scrutiny { background: linear-gradient(135deg, #9cffcf, #61ffaa); }
.storage-layout {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 6px;
align-items: stretch;
}
.storage-layout > div:first-child { display: contents; }
#storage-grid { display: contents; }
.scrutiny-row { padding: 1px 0; border-bottom: none; gap: 6px; font-size: 8px; }
.scrutiny-offline { font-size: 8px; padding: 2px 0; }
.scrutiny-strip,
.storage-strip {
display: flex;
gap: 4px;
flex-wrap: wrap;
overflow: hidden;
margin-top: 4px;
}
.scrutiny-chip,
.storage-chip {
font-family: var(--font-mono);
font-size: 7px;
color: var(--text-dim);
padding: 2px 5px;
border-radius: 999px;
border: 1px solid rgba(0,220,140,0.12);
background: rgba(255,255,255,0.02);
white-space: nowrap;
}
.scrutiny-chip.ok { color: var(--teal-bright); border-color: rgba(0,220,140,0.22); }
.scrutiny-chip.fail { color: var(--red); border-color: rgba(255,68,102,0.24); }
.scrutiny-chip.unk { color: var(--text-dim); border-color: rgba(255,255,255,0.08); }
.scrutiny-chip strong {
color: currentColor;
font-family: var(--font-display);
font-size: 7px;
letter-spacing: 0.6px;
margin-right: 4px;
}
.storage-matrix-card .stats-grid { margin-bottom: 6px; }
.storage-matrix-card .service-footer { margin-top: auto; }
.system-card .card-title,
.service-card .card-title,
.storage-card .card-title { align-items: center; }
@media (max-width: 1360px) {
.services-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
#quick-access-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.row-5 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 960px) {
.services-grid, #quick-access-grid, #storage-grid, .storage-layout, .row-5 { grid-template-columns: 1fr; }
.card { max-height: none; }
.stats-grid { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
</head>
<body>
<div class="scanline"></div>
<div class="wrapper">
<!-- HEADER -->
<header class="header" id="header">
<div class="header-logo">
<div>
<div class="logo-text">KALLILAB</div>
<div class="logo-sub">Control Panel</div>
</div>
</div>
<div class="header-center">
<div class="overall-status">SYSTEM STATUS</div>
<div class="status-indicator">
<span class="status-dot-main" id="overall-dot"></span>
<span id="overall-status-text">LOADING</span>
</div>
</div>
<div class="header-right">
<div class="clock" id="clock">--:--:--</div>
<div class="date-str" id="date-str">---</div>
<div id="last-updated">never updated</div>
</div>
</header>
<!-- SYSTEM STATS ROW -->
<div class="section-header"><span>&#x2B21;</span> SYSTEM</div>
<div class="widget-row row-5" id="stats-row" style="margin-bottom:8px;">
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-cpu"><span class="icon-glyph glyph-cpu"></span></span><span class="service-name">CPU</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="cpu-percent">&#x2014;</div><div class="stat-label">Usage %</div></div>
<div class="stat-block"><div class="stat-num dim" id="cpu-cores">&#x2014;</div><div class="stat-label">Cores</div></div>
<div class="stat-block"><div class="stat-num dim" id="cpu-load">&#x2014;</div><div class="stat-label">Load 5m</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Compute Load</span><span id="cpu-progress-label">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="cpu-progress"></div></div>
<div class="mini-graph" id="cpu-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-memory"><span class="icon-glyph glyph-memory"></span></span><span class="service-name">MEMORY</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="ram-percent">&#x2014;</div><div class="stat-label">Usage %</div></div>
<div class="stat-block"><div class="stat-num dim" id="ram-used">&#x2014;</div><div class="stat-label">Used GB</div></div>
<div class="stat-block"><div class="stat-num dim" id="ram-total">&#x2014;</div><div class="stat-label">Total GB</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Memory Pool</span><span id="ram-progress-label">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="ram-progress"></div></div>
<div class="mini-graph" id="ram-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-network"><span class="icon-glyph glyph-network"></span></span><span class="service-name">NETWORK</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="net-rx">&#x2014;</div><div class="stat-label">&#x2193; Mbps</div></div>
<div class="stat-block"><div class="stat-num" id="net-tx">&#x2014;</div><div class="stat-label">&#x2191; Mbps</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Traffic Flow</span><span id="net-progress-label">0 Mbps</span></div>
<div class="progress-bar"><div class="progress-fill" id="net-progress"></div></div>
<div class="mini-graph" id="net-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-host"><span class="icon-glyph glyph-host"></span></span><span class="service-name">HOST</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="uptime-days">&#x2014;</div><div class="stat-label">Uptime d</div></div>
<div class="stat-block"><div class="stat-num dim" id="host-platform">&#x2014;</div><div class="stat-label">OS</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Host Runtime</span><span id="host-progress-label">&#x2014;</span></div>
<div class="progress-bar"><div class="progress-fill" id="host-progress"></div></div>
<div class="mini-graph" id="host-graph"></div>
</div>
</div>
<div class="card service-card system-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-docker"><span class="icon-glyph glyph-docker"></span></span><span class="service-name">DOCKER</span></div>
<span class="status-pill pill-online">OK</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="docker-running">&#x2014;</div><div class="stat-label">Running</div></div>
<div class="stat-block"><div class="stat-num dim" id="docker-stopped">&#x2014;</div><div class="stat-label">Stopped</div></div>
<div class="stat-block"><div class="stat-num dim" id="docker-total">&#x2014;</div><div class="stat-label">Total</div></div>
</div>
<div class="progress-wrap">
<div class="progress-meta"><span>Runtime Surface</span><span id="docker-progress-label">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="docker-progress"></div></div>
<div class="mini-graph" id="docker-graph"></div>
</div>
</div>
</div>
<!-- STORAGE + SCRUTINY ROW -->
<div class="section-header"><span>&#x2B21;</span> STORAGE &amp; HEALTH</div>
<div class="storage-layout">
<div>
<div class="widget-row row-3" id="storage-grid">
<!-- Disk cards injected by renderer -->
</div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left"><span class="service-icon icon-scrutiny"><span class="icon-glyph glyph-scrutiny"></span></span><span class="service-name">SCRUTINY</span></div>
<span class="status-pill pill-offline" id="scrutiny-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="scrutiny-total">&#x2014;</div><div class="stat-label">Disks</div></div>
<div class="stat-block"><div class="stat-num" id="scrutiny-passed">&#x2014;</div><div class="stat-label">Passed</div></div>
<div class="stat-block"><div class="stat-num danger" id="scrutiny-failed">&#x2014;</div><div class="stat-label">Failed</div></div>
</div>
<div id="scrutiny-list"></div>
</div>
</div>
<!-- SERVICE WIDGETS ROW 1 -->
<div class="section-header"><span>&#x2B21;</span> SERVICES</div>
<div class="services-grid">
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-ha"><span class="icon-glyph glyph-home"></span></span>
<span class="service-name">HOME ASSISTANT</span>
</div>
<span class="status-pill pill-offline" id="ha-pill">OFFLINE</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="ha-lights">&#x2014;</div><div class="stat-label">Lights</div></div>
<div class="stat-block"><div class="stat-num" id="ha-climate">&#x2014;</div><div class="stat-label">Climate</div></div>
<div class="stat-block"><div class="stat-num" id="ha-doors">&#x2014;</div><div class="stat-label">Doors</div></div>
<div class="stat-block"><div class="stat-num danger" id="ha-alerts">&#x2014;</div><div class="stat-label">Alerts</div></div>
</div>
<div class="service-footer"><span id="ha-version">Core automation hub</span><span></span></div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-kuma"><span class="icon-glyph glyph-kuma"></span></span>
<span class="service-name">UPTIME KUMA</span>
</div>
<span class="status-pill pill-offline" id="uk-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="uk-up">&#x2014;</div><div class="stat-label">Up</div></div>
<div class="stat-block"><div class="stat-num danger" id="uk-down">&#x2014;</div><div class="stat-label">Down</div></div>
<div class="stat-block"><div class="stat-num warn" id="uk-paused">&#x2014;</div><div class="stat-label">Paused</div></div>
<div class="stat-block"><div class="stat-num" id="uk-uptime">&#x2014;</div><div class="stat-label">24h %</div></div>
</div>
<div class="service-footer"><span id="uk-footer">No monitor data</span><div class="micro-strip" id="uk-bars"></div></div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-immich"><span class="icon-glyph glyph-image"></span></span>
<span class="service-name">IMMICH</span>
</div>
<span class="status-pill pill-offline" id="immich-pill">OFFLINE</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="immich-photos">&#x2014;</div><div class="stat-label">Photos</div></div>
<div class="stat-block"><div class="stat-num" id="immich-videos">&#x2014;</div><div class="stat-label">Videos</div></div>
<div class="stat-block"><div class="stat-num dim" id="immich-storage">&#x2014;</div><div class="stat-label">Storage</div></div>
</div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-backrest"><span class="icon-glyph glyph-backrest"></span></span>
<span class="service-name">BACKREST</span>
<span class="status-dot dot-unk" id="backrest-status-dot" title="unknown"></span>
</div>
<span class="status-pill pill-offline" id="backrest-pill">OFFLINE</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num dim" id="backrest-last">&#x2014;</div><div class="stat-label">Last Backup</div></div>
<div class="stat-block"><div class="stat-num" id="backrest-repos">&#x2014;</div><div class="stat-label">Repos</div></div>
<div class="stat-block"><div class="stat-num danger" id="backrest-errors">&#x2014;</div><div class="stat-label">Errors</div></div>
</div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-adguard"><span class="icon-glyph glyph-shield"></span></span>
<span class="service-name">ADGUARD DNS</span>
</div>
<span class="status-pill pill-offline" id="adguard-pill">OFFLINE</span>
</div>
<div class="stats-grid" style="margin-bottom:5px;">
<div class="stat-block"><div class="stat-num" id="adguard-total">&#x2014;</div><div class="stat-label">Queries</div></div>
<div class="stat-block"><div class="stat-num" id="adguard-blocked">&#x2014;</div><div class="stat-label">Blocked</div></div>
<div class="stat-block"><div class="stat-num dim" id="adguard-blocked-pct">&#x2014;</div><div class="stat-label">Block %</div></div>
<div class="stat-block"><div class="stat-num dim" id="adguard-latency">&#x2014;</div><div class="stat-label">Latency</div></div>
</div>
<div class="adguard-bar-wrap"><div class="adguard-bar"><div class="adguard-bar-fill" id="adguard-bar-fill"></div></div></div>
</div>
<div class="card service-card">
<div class="card-title">
<div class="card-title-left">
<span class="service-icon icon-services"><span class="icon-glyph glyph-services"></span></span>
<span class="service-name">SERVICES OVERVIEW</span>
</div>
<span class="status-pill pill-offline" id="services-pill">&#x2014;</span>
</div>
<div class="stats-grid">
<div class="stat-block"><div class="stat-num" id="svc-online">&#x2014;</div><div class="stat-label">Online</div></div>
<div class="stat-block"><div class="stat-num warn" id="svc-degraded">&#x2014;</div><div class="stat-label">Degraded</div></div>
<div class="stat-block"><div class="stat-num danger" id="svc-offline">&#x2014;</div><div class="stat-label">Offline</div></div>
<div class="stat-block"><div class="stat-num dim" id="svc-total">&#x2014;</div><div class="stat-label">Total</div></div>
</div>
</div>
</div>
<!-- QUICK ACCESS -->
<div class="section-header"><span>&#x2B21;</span> QUICK ACCESS</div>
<div id="quick-access-grid"></div>
</div><!-- /wrapper -->
<script type="module" src="/assets/js/app.js"></script>
<script>
// Clock
function updateClock() {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
document.getElementById('clock').textContent =
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
document.getElementById('date-str').textContent =
now.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
}
updateClock();
setInterval(updateClock, 1000);
</script>
</body>
</html>
-51
View File
@@ -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
+2 -2
View File
@@ -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