Add custom homelab dashboard stack

This commit is contained in:
2026-04-05 13:43:03 +02:00
parent 450a04a7d3
commit 89b9173c25
38 changed files with 3539 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
const DEFAULT_HEADERS = {
Accept: "application/json",
};
async function fetchJson(path) {
const response = await fetch(path, {
headers: DEFAULT_HEADERS,
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Request failed for ${path}: ${response.status}`);
}
return response.json();
}
export async function fetchDashboardData() {
const [overview, system, services, storage] = await Promise.all([
fetchJson("/api/overview"),
fetchJson("/api/system"),
fetchJson("/api/services"),
fetchJson("/api/storage"),
]);
return { overview, system, services, storage };
}
+46
View File
@@ -0,0 +1,46 @@
import { fetchDashboardData } from "./api.js";
import { getState, setError, subscribe, updateData } from "./state.js";
import { renderHeader } from "./renderers/header.js";
import { renderQuickAccess } from "./renderers/quick-access.js";
import { renderServices } from "./renderers/services.js";
import { renderStats } from "./renderers/stats.js";
import { renderStorage } from "./renderers/storage.js";
let pollTimer = null;
function render(state) {
renderHeader(state);
renderStats(state);
renderStorage(state);
renderServices(state);
}
async function refreshData() {
try {
const payload = await fetchDashboardData();
updateData(payload);
} catch (error) {
console.error("Dashboard refresh failed", error);
setError(error instanceof Error ? error : new Error("Unknown dashboard refresh error"));
} finally {
restartPolling();
}
}
function restartPolling() {
if (pollTimer) {
window.clearInterval(pollTimer);
}
const state = window.__dashboardState?.() ?? null;
const interval = state?.refreshIntervalMs ?? 20000;
pollTimer = window.setInterval(refreshData, interval);
}
subscribe((state) => {
window.__dashboardState = () => state;
render(state);
});
renderQuickAccess();
refreshData();
window.setInterval(() => renderHeader(getState()), 1000);
@@ -0,0 +1,87 @@
function formatTime(now) {
return new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
}
function formatDate(now) {
return new Intl.DateTimeFormat("de-DE", {
weekday: "long",
day: "2-digit",
month: "short",
year: "numeric",
}).format(now).toUpperCase();
}
function statusTone(status) {
if (status === "offline") return "offline";
if (status === "degraded") return "warning";
return "online";
}
function statusLabel(status) {
if (status === "offline") return "OFFLINE";
if (status === "degraded") return "DEGRADED";
return "NOMINAL";
}
export function renderHeader(state) {
const now = new Date();
const { overview, services } = state.data;
const refreshNode = document.getElementById("last-refresh");
const overallTile = document.getElementById("overall-status-tile");
const overallLabel = document.getElementById("overall-status-label");
const overallSummary = document.getElementById("overall-status-summary");
const haTile = document.getElementById("home-assistant-tile");
const haLabel = document.getElementById("ha-status-label");
const haSummary = document.getElementById("ha-status-summary");
const heroCopy = document.getElementById("hero-copy");
document.getElementById("clock-time").textContent = formatTime(now);
document.getElementById("clock-date").textContent = formatDate(now);
if (refreshNode) {
if (state.lastRefreshAt) {
refreshNode.textContent = `LAST REFRESH ${new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(state.lastRefreshAt)} CET`;
} else {
refreshNode.textContent = "LAST REFRESH PENDING";
}
refreshNode.classList.toggle("api-error", Boolean(state.error));
}
const overallTone = statusTone(overview.overall_status);
overallTile.className = "status-tile";
overallTile.style.borderColor = "";
overallLabel.textContent = statusLabel(overview.overall_status);
overallLabel.className = "";
overallSummary.textContent =
`${overview.services.online} services online, ${overview.services.degraded} degraded, ${overview.services.offline} offline`;
overallTile.classList.toggle("api-error", Boolean(state.error));
overallLabel.classList.add(`value-${overallTone === "warning" ? "warning" : overallTone === "offline" ? "danger" : "online"}`);
const haTone = statusTone(overview.home_assistant.status);
haLabel.textContent = overview.home_assistant.status === "online" ? "ONLINE" : "OFFLINE";
haLabel.className = "";
haLabel.classList.add(`value-${haTone === "warning" ? "warning" : haTone === "offline" ? "danger" : "online"}`);
haSummary.textContent =
overview.home_assistant.status === "online"
? `Version ${overview.home_assistant.version ?? "unknown"} / ${overview.home_assistant.response_time_ms ?? "n/a"} ms`
: "Basisstatus derzeit nicht erreichbar";
haTile.className = "status-tile";
if (state.error) {
heroCopy.textContent = `Aggregator API zuletzt fehlerhaft erreichbar. Letzte gueltige Daten bleiben sichtbar. Fehler: ${state.error.message}`;
} else {
heroCopy.textContent =
`Live angebundenes MVP-v1 Dashboard. ${services.summary.docker.running} Container running, ` +
`${services.summary.uptime_kuma.monitors_up} Kuma Monitors up, Home Assistant ${overview.home_assistant.status}.`;
}
}
@@ -0,0 +1,24 @@
const QUICK_LINKS = [
{ section: "Core", title: "Home Assistant", label: "OPEN CONTROL HUB", url: "http://localhost:8123" },
{ section: "Ops", title: "Uptime Kuma", label: "OPEN MONITORS", url: "http://localhost:3001" },
{ section: "Containers", title: "Portainer", label: "OPEN RUNTIME", url: "http://localhost:9000" },
{ section: "Media", title: "Immich", label: "OPEN GALLERY", url: "http://localhost:2283" },
];
export function renderQuickAccess() {
const grid = document.getElementById("quick-grid");
grid.innerHTML = QUICK_LINKS.map(
(link) => `
<article class="card quick-card">
<div class="card-inner">
<span class="card-label">${link.section}</span>
<h2 class="card-title">${link.title}</h2>
<a href="${link.url}" target="_blank" rel="noreferrer">
<span>${link.label}</span>
<span>&gt;</span>
</a>
</div>
</article>
`,
).join("");
}
@@ -0,0 +1,90 @@
function serviceTone(status) {
if (status === "offline") return "offline";
if (status === "degraded") return "warning";
return "online";
}
function healthLabel(status) {
if (status === "offline") return "Offline";
if (status === "degraded") return "Degraded";
return "Healthy";
}
function sourceLabel(source) {
if (source === "home_assistant") return "Core Automation Hub";
if (source === "uptime_kuma") return "External availability and latency surface.";
if (source === "docker") return "Container runtime state without external monitor data.";
return "Service state from aggregator.";
}
function formatTimestamp(value) {
if (!value) return "n/a";
const date = new Date(value);
return new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}
export function renderServices(state) {
const { services, overview } = state.data;
const dockerPill = document.getElementById("docker-summary-pill");
dockerPill.className = `status-pill ${services.summary.docker.source_status === "online" ? "online" : "offline"}`;
dockerPill.textContent = services.summary.docker.source_status === "online" ? "Online" : "Offline";
document.getElementById("docker-running").textContent = String(services.summary.docker.running);
document.getElementById("docker-stopped").textContent = String(services.summary.docker.stopped);
document.getElementById("docker-unhealthy").textContent = String(services.summary.docker.unhealthy);
const kumaPill = document.getElementById("kuma-summary-pill");
kumaPill.className = `status-pill ${services.summary.uptime_kuma.source_status === "online" ? "online" : "offline"}`;
kumaPill.textContent = services.summary.uptime_kuma.source_status === "online" ? "Synced" : "Offline";
document.getElementById("kuma-up").textContent = String(services.summary.uptime_kuma.monitors_up);
document.getElementById("kuma-down").textContent = String(services.summary.uptime_kuma.monitors_down);
document.getElementById("kuma-paused").textContent = String(services.summary.uptime_kuma.monitors_paused);
const ha = services.services.find((service) => service.id === "homeassistant");
if (ha) {
const haPill = document.getElementById("service-ha-pill");
const tone = serviceTone(ha.status);
haPill.className = `status-pill ${tone}`;
haPill.textContent = ha.status === "online" ? "Reachable" : ha.status === "degraded" ? "Degraded" : "Offline";
document.getElementById("service-ha-version").textContent = overview.home_assistant.version ?? "unknown";
document.getElementById("service-ha-version").className = "info";
document.getElementById("service-ha-latency").textContent = ha.latency_ms != null ? `${ha.latency_ms} MS` : "N/A";
document.getElementById("service-ha-latency").className = tone === "offline" ? "offline" : tone === "warning" ? "warning" : "online";
document.getElementById("service-ha-last-check").textContent = formatTimestamp(ha.last_checked);
}
const dynamicServices = services.services.filter((service) => service.id !== "homeassistant").slice(0, 3);
const existingFallbacks = [
document.getElementById("service-card-fallback-1"),
document.getElementById("service-card-fallback-2"),
document.getElementById("service-card-fallback-3"),
];
dynamicServices.forEach((service, index) => {
const node = existingFallbacks[index];
if (!node) return;
node.style.display = "";
const tone = serviceTone(service.status);
const pillClass = tone === "warning" ? "warning" : tone === "offline" ? "offline" : "online";
node.querySelector(".card-label").textContent = service.name;
node.querySelector(".status-pill").className = `status-pill ${pillClass}`;
node.querySelector(".status-pill").textContent = healthLabel(service.status);
node.querySelector(".card-title").textContent = service.name === "Immich" ? "Photo Pipeline" : service.name === "Gitea" ? "Git Platform" : `${service.name} Service`;
node.querySelector(".card-copy").textContent = sourceLabel(service.source);
const rows = node.querySelectorAll(".service-meta-row strong");
rows[0].textContent = service.latency_ms != null ? `${service.latency_ms} MS` : "N/A";
rows[0].className = tone === "offline" ? "offline" : tone === "warning" ? "warning" : "online";
rows[1].textContent = String(service.docker_state).toUpperCase();
rows[1].className = service.docker_state === "stopped" ? "offline" : service.docker_state === "unhealthy" ? "warning" : "online";
});
for (let index = dynamicServices.length; index < existingFallbacks.length; index += 1) {
existingFallbacks[index].style.display = "none";
}
}
@@ -0,0 +1,65 @@
function pct(value) {
return `${Math.round(value)}%`;
}
function gb(value) {
return `${Number(value).toFixed(1)} GB`;
}
function toneForPercent(value) {
if (value >= 85) return "danger";
if (value >= 60) return "warning";
return "online";
}
function setSignal(id, tone) {
const node = document.getElementById(id);
node.className = `signal ${tone === "danger" ? "offline" : tone === "warning" ? "warning" : tone === "info" ? "info" : "online"}`;
}
function setBar(id, fillId, percent, tone) {
const bar = document.getElementById(id);
const fill = document.getElementById(fillId);
bar.className = `metric-bar${tone === "online" ? "" : ` ${tone === "danger" ? "danger" : tone}`}`;
fill.style.width = `${Math.max(0, Math.min(percent, 100))}%`;
}
export function renderStats(state) {
const { system } = state.data;
const cpuTone = toneForPercent(system.cpu.usage_percent);
const ramTone = toneForPercent(system.memory.usage_percent);
const networkTone = "info";
const uptimeTone = system.source.status === "online" ? "online" : "danger";
setSignal("cpu-signal", cpuTone);
document.getElementById("cpu-value").textContent = pct(system.cpu.usage_percent);
document.getElementById("cpu-value").className = `card-value value-${cpuTone}`;
document.getElementById("cpu-cores").textContent = `${system.cpu.cores} CORES`;
document.getElementById("cpu-cores").className = `metric-accent ${cpuTone === "danger" ? "warning" : "online"}`;
document.getElementById("cpu-load").textContent = system.cpu.load_1.toFixed(2);
setBar("cpu-bar", "cpu-bar-fill", system.cpu.usage_percent, cpuTone);
setSignal("ram-signal", ramTone);
document.getElementById("ram-value").textContent = pct(system.memory.usage_percent);
document.getElementById("ram-value").className = `card-value value-${ramTone}`;
document.getElementById("ram-used").textContent = gb(system.memory.used_gb);
document.getElementById("ram-used").className = `metric-accent ${ramTone}`;
document.getElementById("ram-free").textContent = gb(system.memory.available_gb);
document.getElementById("ram-free").className = "metric-accent info";
setBar("ram-bar", "ram-bar-fill", system.memory.usage_percent, ramTone);
setSignal("network-signal", networkTone);
document.getElementById("network-value").textContent = system.network.rx_mbps.toFixed(1);
document.getElementById("network-value").className = "card-value value-info";
document.getElementById("network-tx").textContent = `${system.network.tx_mbps.toFixed(1)} MBPS`;
setBar("network-bar", "network-bar-fill", Math.min(system.network.rx_mbps * 4, 100), networkTone);
const uptimeDays = Math.max(0, Math.floor(system.host.uptime_seconds / 86400));
const uptimeHours = Math.max(0, Math.floor(system.host.uptime_seconds / 3600));
setSignal("uptime-signal", uptimeTone);
document.getElementById("uptime-value").textContent = `${uptimeDays}D`;
document.getElementById("uptime-value").className = `card-value value-${uptimeTone === "danger" ? "danger" : "online"}`;
document.getElementById("uptime-host").textContent = system.source.host_name.toUpperCase();
document.getElementById("uptime-hours").textContent = String(uptimeHours);
setBar("uptime-bar", "uptime-bar-fill", system.source.status === "online" ? 84 : 10, uptimeTone === "danger" ? "danger" : "online");
}
@@ -0,0 +1,54 @@
function gb(value) {
return `${Number(value).toFixed(1)} GB`;
}
function diskTone(status) {
if (status === "critical" || status === "offline") return "danger";
if (status === "warning") return "warning";
return "online";
}
function pillLabel(status) {
if (status === "critical") return "Critical";
if (status === "warning") return "Warning";
if (status === "offline") return "Offline";
return "Stable";
}
export function renderStorage(state) {
const { storage } = state.data;
const rootTone = diskTone(storage.root.status);
const rootPill = document.getElementById("root-storage-pill");
rootPill.className = `status-pill ${rootTone === "danger" ? "offline" : rootTone}`;
rootPill.textContent = pillLabel(storage.root.status);
document.getElementById("root-storage-value").textContent = `${storage.root.usage_percent.toFixed(1)}%`;
document.getElementById("root-storage-value").className = `card-value value-${rootTone === "danger" ? "danger" : rootTone}`;
document.getElementById("root-storage-used").textContent = gb(storage.root.used_gb);
document.getElementById("root-storage-free").textContent = gb(storage.root.free_gb);
const rootBar = document.getElementById("root-storage-bar");
rootBar.className = `metric-bar${rootTone === "online" ? "" : ` ${rootTone === "danger" ? "danger" : rootTone}`}`;
document.getElementById("root-storage-bar-fill").style.width = `${storage.root.usage_percent}%`;
const signal = document.getElementById("disk-matrix-signal");
signal.className = `signal ${storage.summary.overall_status === "degraded" ? "warning" : storage.summary.overall_status === "offline" ? "offline" : "online"}`;
const list = document.getElementById("storage-list");
list.innerHTML = storage.disks
.map((disk) => {
const tone = diskTone(disk.status);
const pillClass = tone === "danger" ? "offline" : tone === "warning" ? "warning" : "online";
const barClass = tone === "online" ? "metric-bar" : `metric-bar ${tone === "danger" ? "danger" : tone}`;
return `
<div class="storage-row">
<strong>${disk.name}</strong>
<span>${disk.mount}</span>
<div class="${barClass}"><span style="width:${Math.max(0, Math.min(disk.usage_percent, 100))}%"></span></div>
<span class="status-pill ${pillClass}">${disk.usage_percent.toFixed(0)}%</span>
</div>
`;
})
.join("");
}
+146
View File
@@ -0,0 +1,146 @@
const DEFAULT_DATA = {
overview: {
generated_at: new Date().toISOString(),
overall_status: "online",
refresh_hint_seconds: 20,
services: { online: 8, degraded: 2, offline: 1, total: 11 },
docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
system: {
cpu_percent: 23,
ram_percent: 61,
root_storage_percent: 49,
network_rx_mbps: 12.4,
network_tx_mbps: 3.1,
uptime_seconds: 864000,
},
home_assistant: {
status: "online",
label: "Home Assistant",
version: "2026.3.4",
response_time_ms: 142,
last_checked: new Date().toISOString(),
},
},
system: {
generated_at: new Date().toISOString(),
source: {
name: "beszel",
status: "online",
host_name: "homelab-01",
agent_name: "beszel-agent",
},
cpu: { usage_percent: 23, cores: 8, load_1: 0.82, load_5: 0.74, load_15: 0.69 },
memory: { used_gb: 19.6, total_gb: 32, available_gb: 12.4, usage_percent: 61 },
network: { primary_interface: "eth0", rx_mbps: 12.4, tx_mbps: 3.1 },
host: { uptime_seconds: 864000, platform: "linux", kernel: "6.8.0" },
},
services: {
generated_at: new Date().toISOString(),
summary: {
overall_status: "degraded",
docker: { running: 18, stopped: 2, unhealthy: 1, total: 20, source_status: "online" },
uptime_kuma: { monitors_up: 8, monitors_down: 1, monitors_paused: 1, total: 10, source_status: "online" },
},
services: [
{
id: "homeassistant",
name: "Home Assistant",
kind: "core",
status: "online",
health: "healthy",
latency_ms: 142,
docker_state: "running",
url: "#",
source: "home_assistant",
last_checked: new Date().toISOString(),
},
{
id: "immich",
name: "Immich",
kind: "service",
status: "degraded",
health: "warning",
latency_ms: 821,
docker_state: "running",
url: "#",
source: "uptime_kuma",
last_checked: new Date().toISOString(),
},
{
id: "gitea",
name: "Gitea",
kind: "service",
status: "online",
health: "healthy",
latency_ms: 98,
docker_state: "running",
url: "#",
source: "uptime_kuma",
last_checked: new Date().toISOString(),
},
{
id: "adguard",
name: "AdGuard",
kind: "service",
status: "offline",
health: "offline",
latency_ms: null,
docker_state: "stopped",
url: "#",
source: "docker",
last_checked: new Date().toISOString(),
},
],
},
storage: {
generated_at: new Date().toISOString(),
summary: { overall_status: "degraded", critical_disks: 0, warning_disks: 1, total_disks: 3 },
root: { name: "rootfs", mount: "/", used_gb: 233.8, total_gb: 480, free_gb: 246.2, usage_percent: 48.7, status: "online" },
disks: [
{ name: "rootfs", mount: "/", used_gb: 233.8, total_gb: 480, free_gb: 246.2, usage_percent: 48.7, status: "online" },
{ name: "data", mount: "/data", used_gb: 712.1, total_gb: 1000, free_gb: 287.9, usage_percent: 71.2, status: "warning" },
{ name: "backup", mount: "/backup", used_gb: 201.4, total_gb: 2000, free_gb: 1798.6, usage_percent: 10.1, status: "online" },
],
},
};
const listeners = new Set();
const state = {
data: structuredClone(DEFAULT_DATA),
isLoading: true,
error: null,
lastRefreshAt: null,
refreshIntervalMs: 20000,
};
export function subscribe(listener) {
listeners.add(listener);
listener(state);
return () => listeners.delete(listener);
}
export function getState() {
return state;
}
export function updateData(payload) {
state.data = payload;
state.isLoading = false;
state.error = null;
state.lastRefreshAt = new Date();
state.refreshIntervalMs = Math.max((payload?.overview?.refresh_hint_seconds ?? 20) * 1000, 15000);
emit();
}
export function setError(error) {
state.isLoading = false;
state.error = error;
emit();
}
function emit() {
for (const listener of listeners) {
listener(state);
}
}