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