import app from "ags/gtk4/app"; import { Astal, Gtk } from "ags/gtk4"; import { execAsync } from "ags/process"; import css from "./homelab.css"; import GLib from "gi://GLib"; const UNRAID_HOST = "10.0.0.15"; const UNRAID_USER = "root"; const SSH_OPTS = [ "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=5", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=1", ]; const REFRESH_INTERVAL_SECONDS = 30; const HISTORY_LIMIT = 24; const WINDOW_MARGIN_TOP = 48; const WINDOW_EDGE_GAP = 48; const ESC_KEYVAL = 65307; type PageId = "dashboard" | "docker" | "services" | "storage" | "network" | "system" | "logs" | "automations" | "auth" | "integrations" | "ai" | "monitoring"; type DockerContainer = { id: string; name: string; image: string; status: string; state: string; health: string; ports: string; restart: string; cpu: number; memory: string; }; type DiskInfo = { filesystem: string; size: string; used: string; available: string; percent: number; mount: string; }; type ServerStatus = { host: string; uptime: string; kernel: string; load: string; load1: number; cpuPercent: number; memory: string; memoryPercent: number; rootDisk: string; rootDiskPercent: number; userDisk: string; userDiskPercent: number; array: string; parity: string; temps: string; docker: string; dockerRunning: number; dockerStopped: number; dockerUnhealthy: number; dockerContainers: DockerContainer[]; disks: DiskInfo[]; smart: string; network: string; networkRxBytes: number; networkTxBytes: number; dockerNetworks: string; ports: string; updates: string; serviceHealth: Record; alerts: string[]; updated: string; }; type MetricSample = { time: string; load1: number; cpuPercent: number; memoryPercent: number; rootDiskPercent: number; userDiskPercent: number; dockerRunning: number; networkRxRate: number; networkTxRate: number; rxBytes: number; txBytes: number; timestamp: number; }; const navItems: { id: PageId; icon: string; label: string }[] = [ { id: "dashboard", icon: "๐Ÿ ", label: "Dashboard" }, { id: "docker", icon: "๐Ÿณ", label: "Docker" }, { id: "services", icon: "๐Ÿ“ฆ", label: "Apps / Services" }, { id: "storage", icon: "๐Ÿ’พ", label: "Storage / Disks" }, { id: "network", icon: "๐ŸŒ", label: "Netzwerk" }, { id: "system", icon: "โš™๏ธ", label: "System" }, { id: "logs", icon: "๐Ÿ“œ", label: "Logs" }, { id: "automations", icon: "๐Ÿค–", label: "Automationen" }, { id: "auth", icon: "๐Ÿ”", label: "Auth / User" }, { id: "integrations", icon: "๐Ÿ”Œ", label: "Integrationen" }, { id: "ai", icon: "๐Ÿง ", label: "AI / Agent" }, { id: "monitoring", icon: "๐Ÿ“Š", label: "Monitoring Advanced" }, ]; const serviceChecks = [ { name: "Unraid", url: `http://${UNRAID_HOST}`, localUrl: "http://127.0.0.1", hint: "Core Web UI", query: "" }, { name: "Navidrome", url: `http://${UNRAID_HOST}:4533`, localUrl: "http://127.0.0.1:4533", hint: "Music", query: "navidrome" }, { name: "Paperless", url: `http://${UNRAID_HOST}:8000`, localUrl: "http://127.0.0.1:8000", hint: "Docs", query: "paperless" }, { name: "Nextcloud", url: `http://${UNRAID_HOST}:8080`, localUrl: "http://127.0.0.1:8080", hint: "Cloud", query: "nextcloud" }, { name: "Ollama", url: `http://${UNRAID_HOST}:11434`, localUrl: "http://127.0.0.1:11434", hint: "AI API", query: "ollama" }, { name: "n8n", url: `http://${UNRAID_HOST}:5678`, localUrl: "http://127.0.0.1:5678", hint: "Automation", query: "n8n" }, { name: "Home Assistant", url: `http://${UNRAID_HOST}:8123`, localUrl: "http://127.0.0.1:8123", hint: "Smart Home", query: "homeassistant" }, { name: "Authentik", url: `http://${UNRAID_HOST}:9000`, localUrl: "http://127.0.0.1:9000", hint: "SSO", query: "authentik" }, ]; let password = ""; let authed = false; let busy = false; let status: ServerStatus | null = null; let errorMessage = ""; let history: MetricSample[] = []; let nextRefreshAt = 0; let timerStarted = false; let armedAction = ""; let activePage: PageId = "dashboard"; let logOutput = "Noch keine Logs geladen."; let logTitle = "Logs"; function shQuote(value: string) { return `'${value.replace(/'/g, `'\\''`)}'`; } function notify(message: string) { execAsync(["notify-send", "Homelab", message]).catch(console.error); } function sshCommand(remoteCommand: string) { const opts = SSH_OPTS.map(shQuote).join(" "); return `SSHPASS=${shQuote(password)} sshpass -e ssh ${opts} ${shQuote(`${UNRAID_USER}@${UNRAID_HOST}`)} ${shQuote(remoteCommand)}`; } function runRemote(remoteCommand: string) { return execAsync(["bash", "-lc", sshCommand(remoteCommand)]); } function parsePercent(value: string) { return clampNumber(Number.parseFloat(value.replace("%", "").trim() || "0"), 0, 100); } function parseSections(output: string) { const sections: Record = {}; let current = ""; for (const line of output.split("\n")) { const match = line.match(/^===([A-Z_]+)===$/); if (match) { current = match[1]; sections[current] = ""; continue; } if (current) { sections[current] += `${line}\n`; } } return sections; } function parseDocker(sections: Record) { const stats: Record = {}; for (const line of (sections.DOCKER_STATS || "").trim().split("\n").filter(Boolean)) { const [name, cpu, memory] = line.split("||"); stats[name] = { cpu: parsePercent(cpu || "0"), memory: memory || "n/a" }; } return (sections.DOCKER_ALL || "") .trim() .split("\n") .filter(Boolean) .map(line => { const [id, name, image, statusText, state, health, ports, restart] = line.split("||"); return { id: id || "", name: name || "unknown", image: image || "n/a", status: statusText || "n/a", state: state || "unknown", health: health || "n/a", ports: ports || "n/a", restart: restart || "n/a", cpu: stats[name]?.cpu || 0, memory: stats[name]?.memory || "n/a", }; }); } function parseDisks(output: string) { return output .trim() .split("\n") .filter(Boolean) .map(line => { const [filesystem, size, used, available, percent, mount] = line.split("||"); return { filesystem: filesystem || "n/a", size: size || "n/a", used: used || "n/a", available: available || "n/a", percent: parsePercent(percent || "0"), mount: mount || "n/a", }; }); } function parseServiceHealth(output: string) { const health: Record = {}; for (const line of output.trim().split("\n").filter(Boolean)) { const [name, code] = line.split("||"); health[name] = code || "n/a"; } return health; } function buildAlerts(nextStatus: ServerStatus) { const alerts: string[] = []; if (nextStatus.dockerUnhealthy > 0) { alerts.push(`${nextStatus.dockerUnhealthy} unhealthy container`); } if (nextStatus.dockerContainers.some(container => container.state !== "running")) { alerts.push(`${nextStatus.dockerStopped} stopped container`); } if (nextStatus.userDiskPercent >= 90 || nextStatus.rootDiskPercent >= 90) { alerts.push("Disk usage high"); } if (nextStatus.load1 >= 4) { alerts.push("High load"); } if (nextStatus.smart.toLowerCase().includes("prefail") || nextStatus.smart.toLowerCase().includes("failed")) { alerts.push("SMART warning"); } if (nextStatus.array.toLowerCase().includes("disabled") || nextStatus.array.toLowerCase().includes("invalid")) { alerts.push("Array warning"); } return alerts.length ? alerts : ["Alles ruhig"]; } function parseStatus(output: string): ServerStatus { const sections = parseSections(output); const updated = GLib.DateTime.new_now_local().format("%H:%M:%S") || ""; const load = sections.LOAD?.trim() || "n/a"; const dockerContainers = parseDocker(sections); const dockerRunning = dockerContainers.filter(container => container.state === "running").length; const dockerStopped = dockerContainers.filter(container => container.state !== "running").length; const dockerUnhealthy = dockerContainers.filter(container => container.health.toLowerCase().includes("unhealthy")).length; const nextStatus: ServerStatus = { host: sections.HOST?.trim() || UNRAID_HOST, uptime: sections.UPTIME?.trim() || "n/a", kernel: sections.KERNEL?.trim() || "n/a", load, load1: Math.max(0, Number.parseFloat(load.split(/\s+/)[0] || "0") || 0), cpuPercent: parsePercent(sections.CPU_PERCENT || "0"), memory: sections.MEMORY?.trim() || "n/a", memoryPercent: parsePercent(sections.MEMORY_PERCENT || "0"), rootDisk: sections.ROOT_DISK?.trim() || "n/a", rootDiskPercent: parsePercent(sections.ROOT_DISK_PERCENT || "0"), userDisk: sections.USER_DISK?.trim() || "n/a", userDiskPercent: parsePercent(sections.USER_DISK_PERCENT || "0"), array: sections.ARRAY?.trim() || "n/a", parity: sections.PARITY?.trim() || "n/a", temps: sections.TEMPS?.trim() || "n/a", docker: sections.DOCKER?.trim() || "n/a", dockerRunning, dockerStopped, dockerUnhealthy, dockerContainers, disks: parseDisks(sections.DISKS || ""), smart: sections.SMART?.trim() || "n/a", network: sections.NETWORK?.trim() || "n/a", networkRxBytes: Math.max(0, Number.parseInt(sections.NETWORK_RX_BYTES?.trim() || "0", 10) || 0), networkTxBytes: Math.max(0, Number.parseInt(sections.NETWORK_TX_BYTES?.trim() || "0", 10) || 0), dockerNetworks: sections.DOCKER_NETWORKS?.trim() || "n/a", ports: sections.PORTS?.trim() || "n/a", updates: sections.UPDATES?.trim() || "n/a", serviceHealth: parseServiceHealth(sections.SERVICE_HEALTH || ""), alerts: [], updated, }; nextStatus.alerts = buildAlerts(nextStatus); return nextStatus; } const statusCommand = String.raw` printf '===HOST===\n' hostname 2>/dev/null || uname -n printf '===UPTIME===\n' uptime -p 2>/dev/null || uptime printf '===KERNEL===\n' uname -r printf '===LOAD===\n' awk '{print $1" "$2" "$3}' /proc/loadavg 2>/dev/null printf '===CPU_PERCENT===\n' read _ u1 n1 s1 i1 iw1 irq1 si1 st1 _ < /proc/stat idle1=$((i1 + iw1)) total1=$((u1 + n1 + s1 + i1 + iw1 + irq1 + si1 + st1)) sleep 1 read _ u2 n2 s2 i2 iw2 irq2 si2 st2 _ < /proc/stat idle2=$((i2 + iw2)) total2=$((u2 + n2 + s2 + i2 + iw2 + irq2 + si2 + st2)) awk -v idle="$((idle2 - idle1))" -v total="$((total2 - total1))" 'BEGIN { if (total > 0) printf "%.0f\n", (1 - idle / total) * 100; else print 0 }' printf '===MEMORY===\n' free -h | awk '/Mem:/ {print $3 " / " $2 " used"}' printf '===MEMORY_PERCENT===\n' free | awk '/Mem:/ { if ($2 > 0) printf "%.0f\n", ($3 / $2) * 100; else print 0 }' printf '===ROOT_DISK===\n' df -h / | awk 'NR==2 {print $3 " / " $2 " used (" $5 ")"}' printf '===ROOT_DISK_PERCENT===\n' df -P / | awk 'NR==2 {gsub("%", "", $5); print $5}' printf '===USER_DISK===\n' df -h /mnt/user 2>/dev/null | awk 'NR==2 {print $3 " / " $2 " used (" $5 ")"}' printf '===USER_DISK_PERCENT===\n' df -P /mnt/user 2>/dev/null | awk 'NR==2 {gsub("%", "", $5); print $5}' printf '===ARRAY===\n' if command -v mdcmd >/dev/null 2>&1; then mdcmd status | grep -E '^(mdState|mdNumDisabled|mdNumInvalid|sbSynced|mdResync|mdResyncPos|mdResyncSize)=' | head -30 elif [ -r /proc/mdcmd ]; then grep -E '^(mdState|mdNumDisabled|mdNumInvalid|sbSynced|mdResync|mdResyncPos|mdResyncSize)=' /proc/mdcmd | head -30 else echo 'Array status unavailable' fi printf '===PARITY===\n' if command -v mdcmd >/dev/null 2>&1; then mdcmd status | grep -E '^(sbSynced|mdResync|mdResyncPos|mdResyncSize|mdResyncDt|mdResyncDb)=' | head -20 else echo 'Parity details unavailable' fi printf '===TEMPS===\n' if command -v sensors >/dev/null 2>&1; then sensors | grep -E 'Package id|Tctl|Tdie|Core [0-9]|temp[0-9]' | head -8 fi if command -v smartctl >/dev/null 2>&1; then for disk in /dev/sd? /dev/nvme?n1; do [ -b "$disk" ] || continue smartctl -A "$disk" 2>/dev/null | awk -v disk="$disk" '/Temperature_Celsius|Current Drive Temperature|Composite Temperature/ {print disk ": " $0; exit}' done | head -8 fi printf '===DOCKER===\n' if command -v docker >/dev/null 2>&1; then docker ps --format '{{.Names}} | {{.Status}}' | head -12 else echo 'Docker unavailable' fi printf '===DOCKER_ALL===\n' if command -v docker >/dev/null 2>&1; then docker ps -aq | while read id; do [ -n "$id" ] || continue name=$(docker inspect -f '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##') image=$(docker inspect -f '{{.Config.Image}}' "$id" 2>/dev/null) status=$(docker inspect -f '{{.State.Status}}' "$id" 2>/dev/null) health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$id" 2>/dev/null) restart=$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$id" 2>/dev/null) ports=$(docker port "$id" 2>/dev/null | paste -sd ', ' -) shown=$(docker ps -a --filter id="$id" --format '{{.Status}}' | head -1) [ -n "$ports" ] || ports=n/a [ -n "$restart" ] || restart=n/a echo "$id||$name||$image||$shown||$status||$health||$ports||$restart" done fi printf '===DOCKER_STATS===\n' if command -v docker >/dev/null 2>&1; then docker stats --no-stream --format '{{.Name}}||{{.CPUPerc}}||{{.MemUsage}}' 2>/dev/null fi printf '===DISKS===\n' df -hP | awk 'NR>1 && ($6 ~ /^\/mnt/ || $6 == "/") {gsub("%", "", $5); print $1 "||" $2 "||" $3 "||" $4 "||" $5 "||" $6}' printf '===SMART===\n' if command -v smartctl >/dev/null 2>&1; then for disk in /dev/sd? /dev/nvme?n1; do [ -b "$disk" ] || continue health=$(smartctl -H "$disk" 2>/dev/null | awk -F: '/overall-health|SMART Health Status/ {gsub(/^ +/, "", $2); print $2; exit}') temp=$(smartctl -A "$disk" 2>/dev/null | awk '/Temperature_Celsius|Current Drive Temperature|Composite Temperature/ {print $NF; exit}') [ -n "$health" ] || health=n/a [ -n "$temp" ] || temp=n/a echo "$disk | health=$health | temp=$temp" done | head -16 else echo 'smartctl unavailable' fi printf '===NETWORK===\n' ip -br addr 2>/dev/null | head -12 printf '===NETWORK_RX_BYTES===\n' awk -F'[: ]+' 'NR>2 && $2 !~ /^lo$/ {rx += $3} END {print rx+0}' /proc/net/dev printf '===NETWORK_TX_BYTES===\n' awk -F'[: ]+' 'NR>2 && $2 !~ /^lo$/ {tx += $11} END {print tx+0}' /proc/net/dev printf '===DOCKER_NETWORKS===\n' if command -v docker >/dev/null 2>&1; then docker network ls --format '{{.Name}} | {{.Driver}} | {{.Scope}}' else echo 'Docker networks unavailable' fi printf '===PORTS===\n' (ss -tulpen 2>/dev/null || netstat -tulpen 2>/dev/null) | head -24 printf '===SERVICE_HEALTH===\n' if command -v curl >/dev/null 2>&1; then while IFS='|' read name url; do [ -n "$name" ] || continue code=$(curl -skL --max-time 3 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) [ -n "$code" ] || code=000 echo "$name||$code" done <<'SERVICES' Unraid|http://127.0.0.1 Navidrome|http://127.0.0.1:4533 Paperless|http://127.0.0.1:8000 Nextcloud|http://127.0.0.1:8080 Ollama|http://127.0.0.1:11434 n8n|http://127.0.0.1:5678 Home Assistant|http://127.0.0.1:8123 Authentik|http://127.0.0.1:9000 SERVICES else echo 'curl||unavailable' fi printf '===UPDATES===\n' if command -v plugin >/dev/null 2>&1; then echo 'Plugin tool available' else echo 'Update check unavailable from shell' fi `; const quickStatusCommand = String.raw` printf '===HOST===\n' hostname 2>/dev/null || uname -n printf '===UPTIME===\n' uptime -p 2>/dev/null || uptime printf '===KERNEL===\n' uname -r printf '===LOAD===\n' awk '{print $1" "$2" "$3}' /proc/loadavg 2>/dev/null printf '===CPU_PERCENT===\n' awk '{print int($1 * 100)}' /proc/loadavg 2>/dev/null printf '===MEMORY===\n' free -h | awk '/Mem:/ {print $3 " / " $2 " used"}' printf '===MEMORY_PERCENT===\n' free | awk '/Mem:/ { if ($2 > 0) printf "%.0f\n", ($3 / $2) * 100; else print 0 }' printf '===ROOT_DISK===\n' df -h / | awk 'NR==2 {print $3 " / " $2 " used (" $5 ")"}' printf '===ROOT_DISK_PERCENT===\n' df -P / | awk 'NR==2 {gsub("%", "", $5); print $5}' printf '===USER_DISK===\n' df -h /mnt/user 2>/dev/null | awk 'NR==2 {print $3 " / " $2 " used (" $5 ")"}' printf '===USER_DISK_PERCENT===\n' df -P /mnt/user 2>/dev/null | awk 'NR==2 {gsub("%", "", $5); print $5}' printf '===ARRAY===\n' echo 'Detail refresh laeuft' printf '===PARITY===\n' echo 'Detail refresh laeuft' printf '===TEMPS===\n' echo 'Detail refresh laeuft' printf '===DOCKER===\n' if command -v docker >/dev/null 2>&1; then docker ps --format '{{.Names}} | {{.Status}}' | head -12 else echo 'Docker unavailable' fi printf '===DOCKER_ALL===\n' if command -v docker >/dev/null 2>&1; then docker ps -a --format '{{.ID}}||{{.Names}}||{{.Image}}||{{.Status}}||{{.State}}||n/a||n/a||n/a' fi printf '===DISKS===\n' df -hP | awk 'NR>1 && ($6 ~ /^\/mnt/ || $6 == "/") {gsub("%", "", $5); print $1 "||" $2 "||" $3 "||" $4 "||" $5 "||" $6}' printf '===SMART===\n' echo 'Detail refresh laeuft' printf '===NETWORK===\n' ip -br addr 2>/dev/null | head -12 printf '===NETWORK_RX_BYTES===\n' awk -F'[: ]+' 'NR>2 && $2 !~ /^lo$/ {rx += $3} END {print rx+0}' /proc/net/dev printf '===NETWORK_TX_BYTES===\n' awk -F'[: ]+' 'NR>2 && $2 !~ /^lo$/ {tx += $11} END {print tx+0}' /proc/net/dev printf '===DOCKER_NETWORKS===\n' echo 'Detail refresh laeuft' printf '===PORTS===\n' echo 'Detail refresh laeuft' printf '===UPDATES===\n' echo 'Detail refresh laeuft' `; function clampNumber(value: number, min: number, max: number) { if (!Number.isFinite(value)) { return min; } return Math.max(min, Math.min(max, value)); } function formatRate(bytesPerSecond: number) { if (bytesPerSecond >= 1024 * 1024) { return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MiB/s`; } if (bytesPerSecond >= 1024) { return `${(bytesPerSecond / 1024).toFixed(1)} KiB/s`; } return `${Math.round(bytesPerSecond)} B/s`; } function addSample(nextStatus: ServerStatus) { const timestamp = Date.now(); const previous = history[history.length - 1]; const seconds = previous ? Math.max(1, (timestamp - previous.timestamp) / 1000) : REFRESH_INTERVAL_SECONDS; const networkRxRate = previous ? Math.max(0, (nextStatus.networkRxBytes - previous.rxBytes) / seconds) : 0; const networkTxRate = previous ? Math.max(0, (nextStatus.networkTxBytes - previous.txBytes) / seconds) : 0; history = [ ...history, { time: nextStatus.updated, load1: nextStatus.load1, cpuPercent: nextStatus.cpuPercent, memoryPercent: nextStatus.memoryPercent, rootDiskPercent: nextStatus.rootDiskPercent, userDiskPercent: nextStatus.userDiskPercent, dockerRunning: nextStatus.dockerRunning, networkRxRate, networkTxRate, rxBytes: nextStatus.networkRxBytes, txBytes: nextStatus.networkTxBytes, timestamp, }, ].slice(-HISTORY_LIMIT); } function sparkline(values: number[], maxValue = 100) { const blocks = ["โ–", "โ–‚", "โ–ƒ", "โ–„", "โ–…", "โ–†", "โ–‡", "โ–ˆ"]; if (!values.length) { return "ยท".repeat(HISTORY_LIMIT); } return values .map(value => { const ratio = clampNumber(value / Math.max(maxValue, 1), 0, 1); return blocks[Math.round(ratio * (blocks.length - 1))]; }) .join(""); } function secondsUntilRefresh() { if (!authed || !nextRefreshAt) { return REFRESH_INTERVAL_SECONDS; } return Math.max(0, Math.ceil((nextRefreshAt - Date.now()) / 1000)); } function scheduleNextRefresh() { nextRefreshAt = Date.now() + REFRESH_INTERVAL_SECONDS * 1000; } function windowLayout() { const geometry = app.monitors[0]?.get_geometry(); const screenWidth = geometry?.width || 1280; const screenHeight = geometry?.height || 720; const width = Math.round(clampNumber(screenWidth - WINDOW_EDGE_GAP * 2, 760, 1180)); const height = Math.round(clampNumber(screenHeight - WINDOW_MARGIN_TOP - WINDOW_EDGE_GAP, 420, 720)); return { width, height, pageWidth: Math.max(420, width - 290), pageHeight: Math.max(260, height - 92), }; } function setBusy(value: boolean) { busy = value; app.get_window("homelab-control")?.queue_draw(); } function applyStatus(output: string) { const nextStatus = parseStatus(output); status = nextStatus; addSample(nextStatus); authed = true; } function refresh(command = statusCommand, showBusy = true) { if (!password) { errorMessage = "Passwort fehlt."; return; } if (showBusy) { setBusy(true); } errorMessage = ""; scheduleNextRefresh(); runRemote(command) .then(output => { applyStatus(output); }) .catch(error => { console.error(error); errorMessage = "Verbindung fehlgeschlagen. Passwort, Netzwerk oder SSH pruefen."; }) .finally(() => { if (showBusy) { setBusy(false); } rebuild(); }); } function login() { if (!password) { errorMessage = "Passwort fehlt."; rebuild(); return; } setBusy(true); errorMessage = ""; runRemote(quickStatusCommand) .then(output => { applyStatus(output); scheduleNextRefresh(); setBusy(false); rebuild(); GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { refresh(statusCommand, false); return GLib.SOURCE_REMOVE; }); }) .catch(error => { console.error(error); errorMessage = "Verbindung fehlgeschlagen. Passwort, Netzwerk oder SSH pruefen."; setBusy(false); rebuild(); }); } function runAction(label: string, command: string) { armedAction = ""; setBusy(true); runRemote(command) .then(() => { notify(`${label} ausgefuehrt.`); setBusy(false); refresh(); }) .catch(error => { console.error(error); errorMessage = `${label} fehlgeschlagen.`; setBusy(false); rebuild(); }); } function loadContainerLogs(container: string) { setBusy(true); runRemote(`docker logs --tail 160 ${shQuote(container)} 2>&1`) .then(output => { logTitle = `Logs: ${container}`; logOutput = output.trim() || "Keine Logs."; activePage = "logs"; }) .catch(error => { console.error(error); logTitle = `Logs: ${container}`; logOutput = "Logs konnten nicht geladen werden."; activePage = "logs"; }) .finally(() => { setBusy(false); rebuild(); }); } function openContainerShell(container: string) { const remote = `docker exec -it ${shQuote(container)} sh`; const ssh = sshCommand(remote); const launch = `kitty bash -lc ${shQuote(ssh)} || alacritty -e bash -lc ${shQuote(ssh)} || foot bash -lc ${shQuote(ssh)} || xterm -e bash -lc ${shQuote(ssh)}`; execAsync(["bash", "-lc", launch]).catch(error => { console.error(error); notify("Kein Terminal fuer Exec Shell gefunden."); }); } function openUrl(url: string) { execAsync(["xdg-open", url]).catch(console.error); } function restartServiceCommand(query: string) { if (!query) { return "echo 'No container mapping configured for this service'"; } return `id=$(docker ps -aq --filter name=${shQuote(query)} | head -1); [ -n "$id" ] && docker restart "$id" || echo 'Container not found'`; } function Card({ title, value, className = "" }: { title: string; value: string; className?: string }) { return ( ); } function StatCard({ title, value, percent }: { title: string; value: string; percent: number }) { const safePercent = clampNumber(percent, 0, 100); return ( ); } function ChartCard({ title, value, values, maxValue = 100, accent = "" }: { title: string; value: string; values: number[]; maxValue?: number; accent?: string }) { const current = values.length ? values[values.length - 1] : 0; return ( ); } function startRefreshTimer() { if (timerStarted) { return; } timerStarted = true; GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { if (authed && !busy && Date.now() >= nextRefreshAt) { refresh(); } else if (authed) { rebuild(); } return GLib.SOURCE_CONTINUE; }); } function HomelabWindow() { return ( { if (keyval === ESC_KEYVAL) { app.quit(); return true; } return false; }} /> {authed ? : } ); } function rebuild() { const win = app.get_window("homelab-control"); if (!win) { return; } win.set_child({authed ? : } as Gtk.Widget); } app.start({ css, instanceName: "homelab-control", main() { startRefreshTimer(); HomelabWindow(); }, });