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:
@@ -65,6 +65,11 @@ def hermes_update() -> dict:
|
|||||||
return maintenance.hermes_update_job()
|
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")
|
@router.post("/maintenance/reboot")
|
||||||
def reboot(body: SudoReq) -> dict:
|
def reboot(body: SudoReq) -> dict:
|
||||||
return maintenance.reboot(body.sudo_password)
|
return maintenance.reboot(body.sudo_password)
|
||||||
|
|||||||
@@ -691,12 +691,9 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
|||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def hermes_update_job() -> dict:
|
def _hermes_update_cmd() -> str:
|
||||||
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
|
"""Die komplette Hermes-Update-Befehlskette (Backup → Update → Doctor → UI-Build →
|
||||||
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
|
Neustarts → Postcheck) — geteilt von hermes_update_job und update_all_job."""
|
||||||
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
|
|
||||||
if busy := _maintenance_busy():
|
|
||||||
return busy
|
|
||||||
git = system.find_hermes_agent_git()
|
git = system.find_hermes_agent_git()
|
||||||
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
|
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
|
||||||
py = os.path.join(path, "venv", "bin", "python")
|
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"
|
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"systemctl --user stop hermes-builtin-ui || true; "
|
||||||
f"cd {path} && {py} -m hermes_cli.main update --yes "
|
f"cd {path} && {py} -m hermes_cli.main update --yes "
|
||||||
f"&& {py} -m hermes_cli.main doctor "
|
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"systemctl --user restart hermes-gateway hermes-builtin-ui "
|
||||||
f"&& sleep 6 && bash {postcheck}")
|
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():
|
def on_done():
|
||||||
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
|
_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)
|
group="maintenance", on_done=on_done)
|
||||||
return {"ok": True, "job_id": job_id}
|
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:
|
def reboot(sudo_password: str | None = None) -> dict:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password(sudo_password):
|
||||||
return err
|
return err
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+163
-158
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CMtwLeqK.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react"
|
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 { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CustomDialog } from "./CustomDialog"
|
import { CustomDialog } from "./CustomDialog"
|
||||||
@@ -245,6 +245,33 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
|||||||
else if (kind === "hermes") doHermesUpdate()
|
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() {
|
async function triggerCheckUpdates() {
|
||||||
setCheckingUpdates(true)
|
setCheckingUpdates(true)
|
||||||
try {
|
try {
|
||||||
@@ -337,6 +364,10 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
|||||||
|
|
||||||
// Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar.
|
// 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")
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -415,11 +446,19 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
|||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Updates</h3>
|
<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}
|
<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">
|
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
|
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} /> Nach Updates suchen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{updates?.last_check && (
|
{updates?.last_check && (
|
||||||
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
|
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user