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";
let hasConfig = false;
const CONFIG_PATH = GLib.getenv("HOMELAB_CONFIG") || `${GLib.getenv("HOME")}/.config/homelab/config.yaml`;
function saveConfig(host: string, user: string, port: string) {
const yaml = `# Homelab Configuration\ngenerated_by: Omeron\n\nserver:\n address: "${host}"\n username: "${user}"\n port: ${port}\n\ncontrol_center:\n refresh_interval: 5\n theme: "dark"\n\nfeatures:\n docker: true\n services: true\n storage: true\n network: true\n monitoring: true\n`;
GLib.mkdir_with_parents(GLib.path_get_dirname(CONFIG_PATH), 0o755);
GLib.file_set_contents(CONFIG_PATH, yaml);
hasConfig = true;
const win = app.get_window("homelab-control");
if (win) win.set_child({!hasConfig ? : (authed ? : )} as Gtk.Widget);
}
function loadConfig() {
const defaults = { host: "10.0.0.15", user: "root", port: 22 };
try {
const [ok, contents] = GLib.file_get_contents(CONFIG_PATH);
if (!ok || !contents) return defaults;
hasConfig = true;
const text = new TextDecoder().decode(contents);
const lines = text.split("\n");
let host = defaults.host;
let user = defaults.user;
let inServer = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("#") || trimmed.length === 0) continue;
if (trimmed === "server:") { inServer = true; continue; }
if (inServer && /^\w+:/.test(trimmed) && !trimmed.startsWith(" ")) inServer = false;
if (!inServer) continue;
const addrMatch = trimmed.match(/address:\s*["']?(.+?)["']?$/);
if (addrMatch) host = addrMatch[1];
const userMatch = trimmed.match(/username:\s*["']?(.+?)["']?$/);
if (userMatch) user = userMatch[1];
}
return { host, user, port: defaults.port };
} catch {
return defaults;
}
}
const CONFIG = loadConfig();
const UNRAID_HOST = CONFIG.host;
const UNRAID_USER = CONFIG.user;
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 ActionButton({ label, command, destructive = false }: { label: string; command: string; destructive?: boolean }) {
return (