1125 lines
44 KiB
TypeScript
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();
|
|
},
|
|
});
|