Files
Thinkpad-Hyprland-Dotfiles/config/hypr/ags/homelab.tsx
2026-04-28 03:59:07 +02:00

1125 lines
44 KiB
TypeScript

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<string, string>;
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<string, string> = {};
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<string, string>) {
const stats: Record<string, { cpu: number; memory: string }> = {};
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<string, string> = {};
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 (
<box class={`card ${className}`} orientation={Gtk.Orientation.VERTICAL} spacing={6}>
<label class="card-title" xalign={0} label={title} />
<label class="card-value" xalign={0} wrap label={value || "n/a"} />
</box>
);
}
function StatCard({ title, value, percent }: { title: string; value: string; percent: number }) {
const safePercent = clampNumber(percent, 0, 100);
return (
<box class="card stat-card" orientation={Gtk.Orientation.VERTICAL} spacing={7}>
<box>
<label class="card-title" xalign={0} hexpand label={title} />
<label class="percent-label" xalign={1} label={`${Math.round(safePercent)}%`} />
</box>
<box class="meter">
<box class="meter-fill" widthRequest={Math.max(4, Math.round(safePercent * 1.9))} />
</box>
<label class="card-value compact" xalign={0} wrap label={value || "n/a"} />
</box>
);
}
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 (
<box class={`chart-card ${accent}`} orientation={Gtk.Orientation.VERTICAL} spacing={8}>
<box>
<label class="card-title" xalign={0} hexpand label={title} />
<label class="chart-value" xalign={1} label={value} />
</box>
<label class="sparkline" xalign={0} label={sparkline(values, Math.max(maxValue, current, 1))} />
<box class="meter">
<box class="meter-fill" widthRequest={Math.max(4, Math.round(clampNumber(current / Math.max(maxValue, 1), 0, 1) * 260))} />
</box>
</box>
);
}
function ActionButton({ label, command, destructive = false }: { label: string; command: string; destructive?: boolean }) {
return (
<button
class={destructive ? "button danger" : "button"}
sensitive={!busy && authed}
onClicked={() => {
if (destructive && armedAction !== label) {
armedAction = label;
rebuild();
return;
}
runAction(label, command);
}}
label={destructive && armedAction === label ? "Nochmal bestaetigen" : label}
/>
);
}
function NavButton({ id, icon, label }: { id: PageId; icon: string; label: string }) {
return (
<button
class={activePage === id ? "nav-button active" : "nav-button"}
onClicked={() => {
activePage = id;
rebuild();
}}
>
<box spacing={8}>
<label class="nav-icon" label={icon} />
<label class="nav-label" xalign={0} label={label} />
</box>
</button>
);
}
function LoginView() {
return (
<box class="login-panel" orientation={Gtk.Orientation.VERTICAL} spacing={14}>
<box orientation={Gtk.Orientation.VERTICAL} spacing={4}>
<label class="title" xalign={0} label="Homelab Controlcenter" />
<label class="subtitle" xalign={0} label={`${UNRAID_USER}@${UNRAID_HOST}`} />
</box>
<entry
visibility={false}
placeholderText="Unraid Passwort"
hexpand
onChanged={entry => {
password = entry.get_text();
}}
onActivate={entry => {
password = entry.get_text();
login();
}}
/>
<button class="button primary" sensitive={!busy} onClicked={login} label={busy ? "Verbinde..." : "Verbinden"} />
{errorMessage ? <label class="error" xalign={0} wrap label={errorMessage} /> : <box />}
</box>
);
}
function AlertsStrip() {
return (
<box class="alerts" spacing={8}>
{(status?.alerts || ["Noch keine Daten"]).map(alert => (
<label class={alert === "Alles ruhig" ? "alert ok" : "alert warn"} label={alert} />
))}
</box>
);
}
function DashboardPage() {
const loadMax = Math.max(2, ...history.map(sample => sample.load1), status?.load1 || 0);
const networkMax = Math.max(1024, ...history.map(sample => Math.max(sample.networkRxRate, sample.networkTxRate)));
const latest = history[history.length - 1];
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<AlertsStrip />
<box class="live-strip" spacing={10}>
<ChartCard title="CPU" value={`${status?.cpuPercent ?? 0}%`} values={history.map(sample => sample.cpuPercent)} accent="cpu" />
<ChartCard title="RAM" value={`${status?.memoryPercent ?? 0}%`} values={history.map(sample => sample.memoryPercent)} accent="memory" />
<ChartCard title="Load 1m" value={`${status?.load1?.toFixed(2) || "0.00"}`} values={history.map(sample => sample.load1)} maxValue={loadMax} accent="load" />
</box>
<box class="live-strip" spacing={10}>
<ChartCard title="Network RX" value={formatRate(latest?.networkRxRate || 0)} values={history.map(sample => sample.networkRxRate)} maxValue={networkMax} accent="network" />
<ChartCard title="Network TX" value={formatRate(latest?.networkTxRate || 0)} values={history.map(sample => sample.networkTxRate)} maxValue={networkMax} accent="network" />
<ChartCard title="Docker" value={`${status?.dockerRunning || 0} running`} values={history.map(sample => sample.dockerRunning)} maxValue={Math.max(5, ...history.map(sample => sample.dockerRunning))} accent="docker" />
</box>
<box class="grid" spacing={10}>
<Card title="Temps" value={status?.temps || "n/a"} />
<Card title="Parity" value={status?.parity || "n/a"} />
<Card title="Power Usage" value="Nicht verfuegbar ohne Sensor / UPS Integration" />
</box>
<box class="grid" spacing={10}>
<StatCard title="Root Disk" value={status?.rootDisk || ""} percent={status?.rootDiskPercent || 0} />
<StatCard title="/mnt/user" value={status?.userDisk || ""} percent={status?.userDiskPercent || 0} />
<Card title="SMART / Health" value={status?.smart || "n/a"} />
</box>
</box>
);
}
function ContainerRow({ container }: { container: DockerContainer }) {
const running = container.state === "running";
return (
<box class="table-row" spacing={8}>
<box class="container-main" orientation={Gtk.Orientation.VERTICAL} spacing={2} hexpand>
<label class="row-title" xalign={0} label={container.name} />
<label class="row-subtitle" xalign={0} wrap label={`${container.image} · ${container.status}`} />
<label class="row-subtitle" xalign={0} wrap label={`Ports: ${container.ports || "n/a"}`} />
</box>
<box class="container-stats" orientation={Gtk.Orientation.VERTICAL} spacing={2}>
<label class={running ? "pill ok" : "pill warn"} label={container.state} />
<label class="row-subtitle" xalign={1} label={`CPU ${container.cpu.toFixed(1)}%`} />
<label class="row-subtitle" xalign={1} label={container.memory} />
</box>
<box class="row-actions" spacing={6}>
<ActionButton label="Start" command={`docker start ${shQuote(container.name)}`} />
<ActionButton label="Stop" command={`docker stop ${shQuote(container.name)}`} destructive />
<ActionButton label="Restart" command={`docker restart ${shQuote(container.name)}`} />
<button class="mini-button" label="Logs" onClicked={() => loadContainerLogs(container.name)} />
<button class="mini-button" label="Shell" onClicked={() => openContainerShell(container.name)} />
</box>
</box>
);
}
function DockerPage() {
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<box class="grid" spacing={10}>
<Card title="Running" value={`${status?.dockerRunning || 0}`} />
<Card title="Stopped" value={`${status?.dockerStopped || 0}`} />
<Card title="Unhealthy" value={`${status?.dockerUnhealthy || 0}`} />
<Card title="Auto Update" value="Button vorbereitet" />
</box>
<box class="actions" spacing={8}>
<ActionButton label="Docker Service restart" command="/etc/rc.d/rc.docker restart" destructive />
<ActionButton label="Auto-Update Container" command="docker ps -q | xargs -r docker update --restart unless-stopped" />
</box>
<box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
{(status?.dockerContainers || []).map(container => <ContainerRow container={container} />)}
</box>
</box>
);
}
function ServicesPage() {
const firstRow = serviceChecks.slice(0, 4);
const secondRow = serviceChecks.slice(4);
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
{[firstRow, secondRow].map(row => (
<box class="service-grid" spacing={10}>
{row.map(service => (
<box class="service-card" orientation={Gtk.Orientation.VERTICAL} spacing={8}>
<box>
<label class="row-title" xalign={0} hexpand label={service.name} />
<label class={status?.serviceHealth[service.name]?.startsWith("2") || status?.serviceHealth[service.name]?.startsWith("3") ? "pill ok" : "pill warn"} label={status?.serviceHealth[service.name] || service.hint} />
</box>
<label class="row-subtitle" xalign={0} label={service.url} />
<box spacing={8}>
<button class="button" label="Open UI" onClicked={() => openUrl(service.url)} />
<ActionButton label="Restart Service" command={restartServiceCommand(service.query)} />
</box>
</box>
))}
</box>
))}
</box>
);
}
function StoragePage() {
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<box class="grid" spacing={10}>
<Card title="Array Status" value={status?.array || "n/a"} />
<Card title="Parity Status" value={status?.parity || "n/a"} />
<Card title="SMART" value={status?.smart || "n/a"} />
</box>
<box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
{(status?.disks || []).map(disk => (
<box class="table-row" spacing={10}>
<box orientation={Gtk.Orientation.VERTICAL} spacing={3} hexpand>
<label class="row-title" xalign={0} label={disk.mount} />
<label class="row-subtitle" xalign={0} label={`${disk.filesystem} · ${disk.used} / ${disk.size} · ${disk.available} free`} />
<box class="meter"><box class="meter-fill" widthRequest={Math.max(4, Math.round(disk.percent * 2.8))} /></box>
</box>
<label class="percent-label" label={`${disk.percent}%`} />
<box class="row-actions" spacing={6}>
<ActionButton label="Spin Down" command={`hdparm -y ${shQuote(disk.filesystem)}`} destructive />
<ActionButton label="SMART Test" command={`smartctl -t short ${shQuote(disk.filesystem)}`} />
</box>
</box>
))}
</box>
</box>
);
}
function NetworkPage() {
const networkMax = Math.max(1024, ...history.map(sample => Math.max(sample.networkRxRate, sample.networkTxRate)));
const latest = history[history.length - 1];
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<box class="live-strip" spacing={10}>
<ChartCard title="Traffic RX" value={formatRate(latest?.networkRxRate || 0)} values={history.map(sample => sample.networkRxRate)} maxValue={networkMax} accent="network" />
<ChartCard title="Traffic TX" value={formatRate(latest?.networkTxRate || 0)} values={history.map(sample => sample.networkTxRate)} maxValue={networkMax} accent="network" />
<Card title="Host" value={`${status?.host || UNRAID_HOST}\n${UNRAID_HOST}`} />
</box>
<box class="grid" spacing={10}>
<Card title="Interfaces" value={status?.network || "n/a"} />
<Card title="Docker Networks" value={status?.dockerNetworks || "n/a"} />
<Card title="Ports / Listening" value={status?.ports || "n/a"} />
</box>
</box>
);
}
function SystemPage() {
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<box class="grid" spacing={10}>
<Card title="Uptime" value={status?.uptime || "n/a"} />
<Card title="Kernel" value={status?.kernel || "n/a"} />
<Card title="Updates" value={status?.updates || "n/a"} />
</box>
<box class="actions" spacing={8}>
<ActionButton label="Reboot" command="reboot" destructive />
<ActionButton label="Shutdown" command="poweroff" destructive />
<ActionButton label="Docker Service restart" command="/etc/rc.d/rc.docker restart" destructive />
<ActionButton label="Update Unraid" command="echo 'Unraid update must be started from Web UI'" />
<button class="button" label="Plugin Manager" onClicked={() => openUrl(`http://${UNRAID_HOST}/Plugins`)} />
</box>
</box>
);
}
function PlaceholderPage({ title, body }: { title: string; body: string }) {
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<Card title={title} value={body} className="wide-card" />
</box>
);
}
function LogsPage() {
return (
<box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
<Card title={logTitle} value={logOutput} className="log-card" />
</box>
);
}
function CurrentPage() {
if (activePage === "dashboard") return <DashboardPage />;
if (activePage === "docker") return <DockerPage />;
if (activePage === "services") return <ServicesPage />;
if (activePage === "storage") return <StoragePage />;
if (activePage === "network") return <NetworkPage />;
if (activePage === "system") return <SystemPage />;
if (activePage === "logs") return <LogsPage />;
if (activePage === "automations") return <PlaceholderPage title="Automationen" body="n8n, Cronjobs und Event-Flows bekommen hier spaeter Restart, Health und Run-History." />;
if (activePage === "auth") return <PlaceholderPage title="Auth / User" body="User, Tokens, SSO und Authentik-Status. Struktur ist vorbereitet, sichere Mutationen kommen separat." />;
if (activePage === "integrations") return <PlaceholderPage title="Integrationen" body="Home Assistant, Authentik, Reverse Proxy und externe APIs als Health-Layer." />;
if (activePage === "ai") return <PlaceholderPage title="AI / Agent" body="AI Diagnose fuer Container, Logs und Alerts. Erst Daten sammeln, dann richtig schlau machen." />;
return <PlaceholderPage title="Monitoring Advanced" body="Langzeit-History, Prometheus/Grafana Bridge und feinere Sensoren passen hier rein." />;
}
function DashboardView() {
const active = navItems.find(item => item.id === activePage);
const layout = windowLayout();
return (
<box class="shell" widthRequest={layout.width} heightRequest={layout.height}>
<box class="sidebar" orientation={Gtk.Orientation.VERTICAL} spacing={6}>
<box class="brand" orientation={Gtk.Orientation.VERTICAL} spacing={2}>
<label class="brand-title" xalign={0} label="Homelab" />
<label class="brand-subtitle" xalign={0} label={status?.host || UNRAID_HOST} />
</box>
{navItems.map(item => <NavButton id={item.id} icon={item.icon} label={item.label} />)}
</box>
<box class="content" orientation={Gtk.Orientation.VERTICAL} spacing={14} hexpand vexpand>
<box class="header">
<box orientation={Gtk.Orientation.VERTICAL} hexpand>
<label class="title" xalign={0} label={active?.label || "Dashboard"} />
<label class="subtitle" xalign={0} label={`${status?.updated || "nie"} · live in ${secondsUntilRefresh()}s · ${UNRAID_USER}@${UNRAID_HOST}`} />
</box>
<button class="icon-button" tooltipText="Aktualisieren" sensitive={!busy} onClicked={() => refresh()}>
<label label="" />
</button>
<button class="icon-button close" tooltipText="Schliessen" onClicked={() => app.quit()}>
<label label="" />
</button>
</box>
{errorMessage ? <label class="error" xalign={0} wrap label={errorMessage} /> : <box />}
<scrolledwindow
class="page-scroll"
hexpand
vexpand
widthRequest={layout.pageWidth}
heightRequest={layout.pageHeight}
hscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
>
<CurrentPage />
</scrolledwindow>
</box>
</box>
);
}
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 (
<window
name="homelab-control"
namespace="homelab-control"
class="homelab-window"
visible
keymode={Astal.Keymode.EXCLUSIVE}
anchor={Astal.WindowAnchor.TOP}
application={app}
>
<Gtk.EventControllerKey onKeyPressed={(_, keyval) => {
if (keyval === ESC_KEYVAL) {
app.quit();
return true;
}
return false;
}} />
<box marginTop={WINDOW_MARGIN_TOP}>
{authed ? <DashboardView /> : <LoginView />}
</box>
</window>
);
}
function rebuild() {
const win = app.get_window("homelab-control");
if (!win) {
return;
}
win.set_child(<box marginTop={WINDOW_MARGIN_TOP}>{authed ? <DashboardView /> : <LoginView />}</box> as Gtk.Widget);
}
app.start({
css,
instanceName: "homelab-control",
main() {
startRefreshTimer();
HomelabWindow();
},
});