dashboard
entfernt dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
backend/.env
|
||||
backend/app/__pycache__
|
||||
backend/app/**/*.pyc
|
||||
assets/.DS_Store
|
||||
@@ -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=
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 +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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pydantic-settings==2.10.1
|
||||
httpx==0.28.1
|
||||
@@ -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>⬡</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">—</div><div class="stat-label">Usage %</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="cpu-cores">—</div><div class="stat-label">Cores</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="cpu-load">—</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">—</div><div class="stat-label">Usage %</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="ram-used">—</div><div class="stat-label">Used GB</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="ram-total">—</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">—</div><div class="stat-label">↓ Mbps</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="net-tx">—</div><div class="stat-label">↑ 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">—</div><div class="stat-label">Uptime d</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="host-platform">—</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">—</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">—</div><div class="stat-label">Running</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="docker-stopped">—</div><div class="stat-label">Stopped</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="docker-total">—</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>⬡</span> STORAGE & 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">—</div><div class="stat-label">Disks</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="scrutiny-passed">—</div><div class="stat-label">Passed</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="scrutiny-failed">—</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>⬡</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">—</div><div class="stat-label">Lights</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="ha-climate">—</div><div class="stat-label">Climate</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="ha-doors">—</div><div class="stat-label">Doors</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="ha-alerts">—</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">—</div><div class="stat-label">Up</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="uk-down">—</div><div class="stat-label">Down</div></div>
|
||||
<div class="stat-block"><div class="stat-num warn" id="uk-paused">—</div><div class="stat-label">Paused</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="uk-uptime">—</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">—</div><div class="stat-label">Photos</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="immich-videos">—</div><div class="stat-label">Videos</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="immich-storage">—</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">—</div><div class="stat-label">Last Backup</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="backrest-repos">—</div><div class="stat-label">Repos</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="backrest-errors">—</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">—</div><div class="stat-label">Queries</div></div>
|
||||
<div class="stat-block"><div class="stat-num" id="adguard-blocked">—</div><div class="stat-label">Blocked</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="adguard-blocked-pct">—</div><div class="stat-label">Block %</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="adguard-latency">—</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">—</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block"><div class="stat-num" id="svc-online">—</div><div class="stat-label">Online</div></div>
|
||||
<div class="stat-block"><div class="stat-num warn" id="svc-degraded">—</div><div class="stat-label">Degraded</div></div>
|
||||
<div class="stat-block"><div class="stat-num danger" id="svc-offline">—</div><div class="stat-label">Offline</div></div>
|
||||
<div class="stat-block"><div class="stat-num dim" id="svc-total">—</div><div class="stat-label">Total</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUICK ACCESS -->
|
||||
<div class="section-header"><span>⬡</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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user