Update-all-Button: alle ausstehenden System-Updates in EINEM Job

Neuer POST /api/maintenance/update-all kettet die AUSSTEHENDEN Updates
sequenziell (Engine -> Router -> Hermes -> OS) in einem maintenance-Job.
Bewusst reine Wiederverwendung: jeder Teil ist exakt der Befehl des
Einzel-Updates inkl. dessen Backup/Postcheck/Selbst-Rollback; &&-Kette
stoppt beim ersten Fehler, Banner-Zeilen im Log zeigen den Schritt.
OS zuletzt (breitester Eingriff, braucht als einziges das Box-Passwort;
fehlt es, laufen die sudo-freien Teile trotzdem und OS wird uebersprungen).
Hermes-Befehlskette in _hermes_update_cmd() extrahiert (DRY).

UI (SystemDrawer/Updates): Button 'Alle aktualisieren (N)' neben der
Update-Suche, nur sichtbar wenn etwas aussteht, gesperrt waehrend ein
Wartungs-Job laeuft, mit Bestaetigungs-Dialog der die Kette benennt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-08 17:36:52 +02:00
parent 49807ace7b
commit f2d357c5d8
6 changed files with 284 additions and 180 deletions
+5
View File
@@ -65,6 +65,11 @@ def hermes_update() -> dict:
return maintenance.hermes_update_job()
@router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict:
return maintenance.update_all_job(body.sudo_password)
@router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password)
+63 -8
View File
@@ -691,12 +691,9 @@ def swap_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)."""
if busy := _maintenance_busy():
return busy
def _hermes_update_cmd() -> str:
"""Die komplette Hermes-Update-Befehlskette (Backup → Update → Doctor → UI-Build →
Neustarts → Postcheck) — geteilt von hermes_update_job und update_all_job."""
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")
@@ -714,7 +711,7 @@ def hermes_update_job() -> dict:
f"&& rm -rf {hui_dist} && cp -r /tmp/h-build {hui_dist}; fi"
)
cmd = (f"bash {backup} || true; "
return (f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; "
f"cd {path} && {py} -m hermes_cli.main update --yes "
f"&& {py} -m hermes_cli.main doctor "
@@ -723,14 +720,72 @@ def hermes_update_job() -> dict:
f"systemctl --user restart hermes-gateway hermes-builtin-ui "
f"&& sleep 6 && bash {postcheck}")
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)."""
if busy := _maintenance_busy():
return busy
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",
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def update_all_job(sudo_password: str | None = None) -> dict:
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy():
return busy
upd = updates()
parts: list[tuple[str, str]] = []
if upd.get("engine") and ENGINE_UPDATE_CMD:
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
if upd.get("swap") and SWAP_UPDATE_CMD:
parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD))
if any(c.get("update") is True for c in upd.get("components") or []):
parts.append(("Hermes-Agent", _hermes_update_cmd()))
os_pending = (upd.get("os") or 0) > 0
if os_pending:
if err := check_sudo_needs_password(sudo_password):
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
if not parts:
return err
os_pending = False
else:
parts.append(("OS (apt)", (
"sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")))
if not parts:
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
cmd = " && ".join(
f"(echo; echo '════════ [{i + 1}/{len(parts)}] {label} ════════'; {c})"
for i, (label, c) in enumerate(parts))
if not os_pending:
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
labels = "".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done,
sudo_password=sudo_password)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
def reboot(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<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-CDoBbBNY.js"></script>
<script type="module" crossorigin src="/assets/index-BrJPl95N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CMtwLeqK.css">
</head>
<body>
+40 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle } from "lucide-react"
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle, DownloadCloud } from "lucide-react"
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
@@ -245,6 +245,33 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
else if (kind === "hermes") doHermesUpdate()
}
// „Alle aktualisieren": kettet alle AUSSTEHENDEN Updates in EINEM Wartungs-Job
// (Engine → Router → Hermes → OS; das Backend nutzt exakt die Einzel-Update-Befehle
// inkl. deren Backup/Postcheck/Rollback — bei einem Fehler stoppt die Kette).
function triggerUpdateAll() {
const teile = [
updates?.engine ? "Engine" : null,
updates?.swap ? "Router" : null,
updates?.components?.some((c) => c.update === true) ? "Hermes-Agent" : null,
updates?.os ? `OS (${updates.os} Pakete)` : null,
].filter(Boolean)
showConfirm(
"Alle Updates einspielen?",
`Nacheinander in einem Job: ${teile.join(" → ")}. Jeder Schritt sichert und prüft sich selbst; schlägt einer fehl, stoppt der Rest. (OS braucht das Box-Passwort aus den Einstellungen — fehlt es, wird OS übersprungen.)`,
async () => {
try {
const res = await api<any>("/api/maintenance/update-all", { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
if (res?.status === "nothing") { showAlert("Nichts zu tun", "Es stehen keine Updates aus."); return }
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `„Alle aktualisieren" konnte nicht starten: ${e.message}`)
}
},
)
}
async function triggerCheckUpdates() {
setCheckingUpdates(true)
try {
@@ -337,6 +364,10 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
// Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar.
const maintenanceJob = jobs.find(j => (j.state === "running" || j.state === "queued") && j.group === "maintenance")
// Wie viele Update-ARTEN ausstehen (für den „Alle aktualisieren"-Button).
const pendingAll =
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine ? 1 : 0) + (updates.swap ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
return (
<>
@@ -415,11 +446,19 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<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">Updates</h3>
<div className="flex items-center gap-3">
{pendingAll > 0 && (
<button onClick={triggerUpdateAll} disabled={!!maintenanceJob}
className="flex items-center gap-1 rounded-md border border-primary/40 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary transition-colors hover:bg-primary/20 disabled:opacity-50">
<DownloadCloud className="h-3 w-3" /> Alle aktualisieren ({pendingAll})
</button>
)}
<button onClick={triggerCheckUpdates} disabled={checkingUpdates || !!maintenanceJob}
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>
{updates?.last_check && (
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
)}