Feat: System-Wartung-Rework + Hermes-Update-Button

Wartungs-Tab im SystemDrawer neu strukturiert:
- UPDATES: eine einheitliche Liste (OS, Engine, Hermes-Agent, Modell-Upgrades) mit
  Status + Aktion-Button, der nur aktiv ist wenn ein Update ansteht (statt Klick-Karten,
  die sofort updaten). Konsistent mit der Dashboard-UpdatesCard.
- DIENSTE: neue Sektion, alle systemd-Units mit Status-Punkt (aus /api/system/services,
  jetzt inkl. mem0-service) + Restart + Logs-Sprung.
- BACKUP: kompakt (letztes Backup + Snapshot-Button + Restore-Hinweis).
- GEFAHRENZONE: Reboot abgetrennt. Jobs nur wenn vorhanden.

Hermes-Update-Button: POST /api/maintenance/hermes-update -> Job (Backup -> `hermes update
--yes` (git pull + deps) -> hermes-gateway restart). Backend: hermes_update_job + USER_SERVICES
um mem0-service/hermes-terminal ergaenzt (Restart ging vorher nicht), mem0 in services-API.

Live verifiziert: Button hat Hermes d470ed0 -> 3b44a3c aktualisiert, Integration intakt
(memory.provider, Plugin laedt), Pre-Update-Backup angelegt, Drawer rendert fehlerfrei.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 21:59:04 +02:00
parent b38e3360c5
commit 58dda66f84
10 changed files with 681 additions and 611 deletions
+5
View File
@@ -45,6 +45,11 @@ def engine_update(body: SudoReq) -> dict:
return res
@router.post("/maintenance/hermes-update")
def hermes_update() -> dict:
return maintenance.hermes_update_job()
@router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password)
+11 -1
View File
@@ -12,7 +12,9 @@ import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL
from services import backup as backup_svc
from services.agent import agent_status
from services.gateway import gateway_reachable
@@ -36,6 +38,13 @@ def status() -> dict:
return system_status()
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
@@ -47,6 +56,7 @@ def services() -> dict:
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
{"name": "Mem0 (Gedächtnis)", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
+20 -1
View File
@@ -19,7 +19,7 @@ from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-dashboard", "hermes-webui"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-terminal", "mem0-service"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -346,6 +346,25 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
return {"ok": True, "job_id": job_id}
def hermes_update_job() -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
git = system.find_hermes_agent_git()
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
py = os.path.join(path, "venv", "bin", "python")
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
cmd = (f"bash {backup} || true; "
f"cd {path} && {py} -m hermes_cli.main update --yes "
f"&& systemctl --user restart hermes-gateway")
def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", cmd], "Hermes-Agent-Update", on_done=on_done)
return {"ok": True, "job_id": job_id}
def reboot(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
@@ -1,4 +1,4 @@
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-DkZXHl-q.js";/**
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-cq5kdO0r.js";/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DkZXHl-q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qbOD334z.css">
<script type="module" crossorigin src="/assets/index-cq5kdO0r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3d2FUwyu.css">
</head>
<body>
<div id="root"></div>
+139 -113
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle, Save } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText } from "lucide-react"
import { api, type Job, type UpdatesResp, type ServicesResp } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
@@ -11,13 +11,53 @@ interface SystemDrawerProps {
defaultTab?: "maintenance" | "logs" | "settings"
}
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
const SERVICES = [
{ id: "llama-swap", label: "Llama Swap", type: "system" },
{ id: "mission-control-2", label: "Mission Control 2", type: "user" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user" }
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
]
function UpdateRow({ icon: Icon, name, status, available, busy, actionLabel, onAction }: {
icon: any; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", available ? "text-amber-400" : "text-muted-foreground")} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
@@ -36,6 +76,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [checkingUpdates, setCheckingUpdates] = useState(false)
const [backupMsg, setBackupMsg] = useState("")
const [backupRunning, setBackupRunning] = useState(false)
const [services, setServices] = useState<ServicesResp | null>(null)
const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([])
const [hermesUpdating, setHermesUpdating] = useState(false)
// Custom Dialog State
const [dialog, setDialog] = useState<{
@@ -115,6 +158,15 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
.catch((e) => console.error("Error loading jobs", e))
}
function loadServices() {
api<ServicesResp>("/api/system/services").then(setServices).catch(() => {})
}
function loadBackups() {
api<{ backups: { snapshot: string; size_mb?: number }[] }>("/api/system/backups")
.then((d) => setBackups(d.backups || [])).catch(() => {})
}
function loadServiceLogs(service: string) {
setLoadingLogs(true)
setLogStatus(null)
@@ -146,9 +198,12 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
if (!open) return
loadUpdates()
loadJobs()
loadServices()
loadBackups()
const timer = setInterval(() => {
loadJobs()
loadUpdates()
loadServices()
}, 3000)
return () => clearInterval(timer)
}, [open])
@@ -179,6 +234,24 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
}
}
function triggerHermesUpdate() {
showConfirm(
"Hermes-Agent aktualisieren",
"Zieht die neuesten Änderungen aus git, installiert Abhängigkeiten neu und startet den Hermes-Gateway neu (vorher automatisches Backup). Fortschritt unter Hintergrund-Aufgaben.",
async () => {
setHermesUpdating(true)
try {
await api<{ job_id: string }>("/api/maintenance/hermes-update", { method: "POST" })
loadJobs()
} catch (e: any) {
showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`)
} finally {
setHermesUpdating(false)
}
},
)
}
async function triggerCheckUpdates() {
setCheckingUpdates(true)
try {
@@ -228,7 +301,8 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
setBackupMsg("Snapshot wird erzeugt...")
try {
const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" })
setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Dateien)` : "Keine Änderungen zu sichern.")
setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Komponenten)` : "Backup fehlgeschlagen.")
loadBackups()
} catch (e: any) {
setBackupMsg(`Fehler: ${e.message}`)
} finally {
@@ -341,119 +415,71 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" && (
<>
{/* Quick Actions */}
<div className="space-y-3">
{/* Updates */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3>
<div className="flex items-center gap-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Updates</h3>
<button onClick={triggerCheckUpdates} disabled={checkingUpdates}
className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} /> Nach Updates suchen
</button>
</div>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
</span>
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
)}
<button
onClick={triggerCheckUpdates}
disabled={checkingUpdates}
className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50"
>
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} />
Nach Updates suchen
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={triggerOsUpdate}
className="flex flex-col items-start gap-1 p-4 rounded-xl border border-border/60 bg-background/20 hover:border-primary/50 transition-all text-left group"
>
<Shield className="h-5 w-5 text-cyan-400 mb-1 group-hover:scale-105 transition-transform" />
<span className="text-xs font-semibold">OS Update (apt)</span>
<span className="text-[10px] text-muted-foreground">
{updates?.os ? `${updates.os} Updates verfügbar` : "Auf neuestem Stand"}
</span>
</button>
<button
onClick={triggerEngineUpdate}
className="flex flex-col items-start gap-1 p-4 rounded-xl border border-border/60 bg-background/20 hover:border-primary/50 transition-all text-left group"
>
<Server className="h-5 w-5 text-violet-400 mb-1 group-hover:scale-105 transition-transform" />
<span className="text-xs font-semibold">Engine Update</span>
<span className="text-[10px] text-muted-foreground">
{updates?.engine ? "Update verfügbar" : "Auf neuestem Stand"}
</span>
</button>
</div>
<button
onClick={triggerReboot}
className="flex w-full items-center gap-3 p-3 rounded-xl border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold"
>
<Power className="h-4.5 w-4.5" />
<div>
<div>Host-System neu starten (Reboot)</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet das gesamte Betriebssystem des Homelabs neu</div>
</div>
</button>
</div>
{/* Backup / Snapshot */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">System-Backup &amp; Snapshot</h3>
<div className="p-4 rounded-xl border border-border/60 bg-background/20 space-y-3">
<p className="text-[10px] text-muted-foreground leading-normal">
Voll-Backup: Gedächtnis (Mem0), Hermes-Config &amp; Secrets, llama-swap-Config.
Läuft auch täglich automatisch. Wiederherstellen per <code>deploy/restore.sh</code> (siehe docs/BACKUP.md).
</p>
<button
onClick={doBackup}
disabled={backupRunning}
className="w-full h-9 flex items-center justify-center gap-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer disabled:opacity-50 shadow-md shadow-primary/10"
>
<Save className={cn("h-4 w-4", backupRunning && "animate-pulse")} /> Snapshot erstellen
</button>
{backupMsg && (
<div className="text-[10px] font-mono text-primary bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
{backupMsg}
</div>
)}
</div>
</div>
{/* Modell Upgrades */}
{updates?.model_list && updates.model_list.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Upgrades</h3>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
</div>
<div className="space-y-2">
{updates.model_list.map((m) => (
<div key={m.role} className="p-3 rounded-xl border border-border/60 bg-background/20 flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold">{m.title}</div>
<div className="text-[10px] font-mono text-muted-foreground">{m.repo}</div>
<div className="text-[10px] text-primary font-semibold uppercase mt-0.5">Rolle: {m.role}</div>
</div>
<button
onClick={() => triggerModelUpgrade(m.repo, m.role)}
className="flex items-center gap-1.5 text-[10px] font-semibold text-emerald-400 hover:text-emerald-300 border border-emerald-500/20 bg-emerald-500/5 hover:bg-emerald-500/10 px-2 py-1 rounded-lg transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
Upgrade
</button>
</div>
</div>
<div className="space-y-1.5">
<UpdateRow icon={Shield} name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Aktualisieren" onAction={triggerOsUpdate} />
<UpdateRow icon={Server} name="Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Aktualisieren" onAction={triggerEngineUpdate} />
{(() => {
const h = updates?.components?.find((c) => c.key === "hermes_agent")
return (
<UpdateRow icon={Bot} name="Hermes-Agent" available={h?.update === true} busy={hermesUpdating} status={h?.update === true ? `Update: ${h.latest}` : h?.reachable === false ? "offline" : "aktuell"} actionLabel="Aktualisieren" onAction={triggerHermesUpdate} />
)
})()}
{updates?.model_list?.map((m) => (
<UpdateRow key={m.role} icon={Box} name={`Modell · ${m.role}`} available={true} status={m.title} actionLabel="Upgrade" onAction={() => triggerModelUpgrade(m.repo, m.role)} />
))}
</div>
</div>
{/* Dienste */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Dienste</h3>
<div className="space-y-1.5">
{SERVICES.map((s) => (
<ServiceRow key={s.id} label={s.label} system={s.type === "system"} ok={services?.services.find((x) => x.name.toLowerCase().includes(s.reach))?.ok} busy={restartingServices[s.id]} onRestart={() => restartService(s.id)} onLogs={() => { setSelectedService(s.id); setActiveTab("logs") }} />
))}
</div>
</div>
{/* Backup */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Backup</h3>
<div className="rounded-lg border border-border/50 bg-background/20 px-3 py-2.5 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}</div>
<div className="text-[10px] text-muted-foreground">{backups.length} Snapshots · Restore per CLI (restore.sh)</div>
</div>
<button onClick={doBackup} disabled={backupRunning} className="flex items-center gap-1.5 text-[11px] font-semibold rounded-lg px-2.5 py-1 bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50 shrink-0">
<Camera className={cn("h-3.5 w-3.5", backupRunning && "animate-pulse")} /> Snapshot
</button>
</div>
{backupMsg && (
<div className="text-[10px] font-mono text-primary bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">{backupMsg}</div>
)}
</div>
{/* Gefahrenzone */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-red-400/80">Gefahrenzone</h3>
<button onClick={triggerReboot} className="flex w-full items-center gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold">
<Power className="h-4.5 w-4.5" />
<div>
<div>Host-System neu starten</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet die ganze Box neu</div>
</div>
</button>
</div>
{/* Active Jobs */}
<div className="space-y-3">