Fix: Engine-Update-Badge bleibt nicht mehr haengen + Update-Detail-Fenster

- engine_update_job leert jetzt _engine_cache nach Abschluss (on_done),
  sonst zeigte das Dashboard bis zu 1h "Update verfuegbar" trotz erfolgter
  Aktualisierung (1h-Cache wurde nie invalidiert wie bei den anderen Jobs).
- check_updates_job leert zusaetzlich _comp_cache, damit "Nach Updates suchen"
  auch den Hermes-Status frisch prueft.
- Neu: GET /api/maintenance/update-details (os|engine|hermes) liefert, was
  genau aktualisiert wird (apt-Paketliste, Engine Build X->Y + Release-Notes,
  Hermes-Commits HEAD..origin/branch).
- Frontend: "Aktualisieren"-Buttons -> "Anzeigen"; oeffnen ein Detail-Fenster
  mit den konkreten Aenderungen, erst "Jetzt aktualisieren" startet das Update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 10:53:40 +02:00
parent 209afebefa
commit e2b3bb7088
9 changed files with 571 additions and 325 deletions
+7
View File
@@ -21,6 +21,13 @@ class RestartReq(BaseModel):
def updates() -> dict: def updates() -> dict:
return maintenance.updates() return maintenance.updates()
@router.get("/maintenance/update-details")
def update_details(kind: str) -> dict:
if kind not in ("os", "engine", "hermes"):
raise HTTPException(400, "Unbekannte Update-Art.")
return maintenance.update_details(kind)
@router.post("/maintenance/check-updates") @router.post("/maintenance/check-updates")
def check_updates(body: SudoReq) -> dict: def check_updates(body: SudoReq) -> dict:
res = maintenance.check_updates_job(body.sudo_password) res = maintenance.check_updates_job(body.sudo_password)
+81 -1
View File
@@ -245,6 +245,80 @@ def updates() -> dict:
"components": _components_cached()} "components": _components_cached()}
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
def os_update_details() -> dict:
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat)."""
out_pkgs: list[dict] = []
try:
out = subprocess.run(["bash", "-c", "apt list --upgradable 2>/dev/null"],
capture_output=True, text=True, timeout=20)
for line in (out.stdout or "").splitlines():
# Format: name/repo neue_version arch [upgradable from: alte_version]
m = re.match(r"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s*([^\]]+)\]",
line.strip())
if m:
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
"current": m.group(3).strip()})
out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs}
except Exception as exc: # noqa: BLE001
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
def engine_update_details() -> dict:
"""Installierte vs. neueste Engine-Build-Nummer + Release-Name/-Notizen/-Link."""
info: dict = {"kind": "engine", "installed_build": _installed_engine_build(),
"latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None}
try:
rel = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases/latest",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
info["name"] = rel.get("name") or tag
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
def hermes_update_details() -> dict:
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>)."""
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
git = system.find_hermes_agent_git()
if not git or not git.get("path"):
info["error"] = "Hermes-Agent-Repo nicht gefunden."
return info
path = git["path"]
try:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
info["branch"] = branch
subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr",
f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10)
commits = []
for line in (log.stdout or "").splitlines():
parts = line.split("\x1f")
if len(parts) == 3:
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
info["commits"] = commits
info["behind"] = len(commits)
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
def update_details(kind: str) -> dict:
return {"os": os_update_details, "engine": engine_update_details,
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
def _run(cmd: list[str], sudo_password: str | None = None) -> dict: def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
actual_cmd = list(cmd) actual_cmd = list(cmd)
has_sudo = False has_sudo = False
@@ -321,6 +395,7 @@ def check_updates_job(sudo_password: str | None = None) -> dict:
def on_done(): def on_done():
_engine_cache.update(ts=0.0, avail=False) _engine_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
cmd = "sudo apt-get update" cmd = "sudo apt-get update"
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password) job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
@@ -340,9 +415,14 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
return None return None
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password(sudo_password):
return err return err
def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
# update-engine.sh läuft via sudo als root und startet llama-swap am Ende selbst neu. # update-engine.sh läuft via sudo als root und startet llama-swap am Ende selbst neu.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
"Engine-Update (llama.cpp Vulkan)", sudo_password=sudo_password) "Engine-Update (llama.cpp Vulkan)",
on_done=on_done, sudo_password=sudo_password)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
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="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-By5Xg5Lz.js"></script> <script type="module" crossorigin src="/assets/index-Qs-v42ar.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CH4ZNiiA.css"> <link rel="stylesheet" crossorigin href="/assets/index--0Qg2tjI.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+142 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText } from "lucide-react" import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight } from "lucide-react"
import { api, type Job, type UpdatesResp, type ServicesResp } 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"
@@ -80,6 +80,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([]) const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([])
const [hermesUpdating, setHermesUpdating] = useState(false) const [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null)
// Custom Dialog State // Custom Dialog State
const [dialog, setDialog] = useState<{ const [dialog, setDialog] = useState<{
type: "alert" | "confirm" type: "alert" | "confirm"
@@ -234,11 +237,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
} }
} }
function triggerHermesUpdate() { async function doHermesUpdate() {
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) setHermesUpdating(true)
try { try {
await api<{ job_id: string }>("/api/maintenance/hermes-update", { method: "POST" }) await api<{ job_id: string }>("/api/maintenance/hermes-update", { method: "POST" })
@@ -248,8 +247,26 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
} finally { } finally {
setHermesUpdating(false) setHermesUpdating(false)
} }
}, }
)
// Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde.
async function openUpdateDetails(kind: "os" | "engine" | "hermes") {
setDetail({ kind, loading: true, data: null })
try {
const d = await api<UpdateDetails>(`/api/maintenance/update-details?kind=${kind}`)
setDetail({ kind, loading: false, data: d })
} catch (e: any) {
setDetail({ kind, loading: false, data: { kind, error: e.message } })
}
}
// Bestätigung aus dem Detail-Fenster → startet das passende Update.
function applyFromDetail() {
const kind = detail?.kind
setDetail(null)
if (kind === "os") triggerOsUpdate()
else if (kind === "engine") triggerEngineUpdate()
else if (kind === "hermes") doHermesUpdate()
} }
async function triggerCheckUpdates() { async function triggerCheckUpdates() {
@@ -428,12 +445,12 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<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>
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
<UpdateRow icon={Shield} iconClass="text-cyan-400" name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Aktualisieren" onAction={triggerOsUpdate} /> <UpdateRow icon={Shield} iconClass="text-cyan-400" name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("os")} />
<UpdateRow icon={Server} iconClass="text-violet-400" name="Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Aktualisieren" onAction={triggerEngineUpdate} /> <UpdateRow icon={Server} iconClass="text-violet-400" name="Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("engine")} />
{(() => { {(() => {
const h = updates?.components?.find((c) => c.key === "hermes_agent") const h = updates?.components?.find((c) => c.key === "hermes_agent")
return ( return (
<UpdateRow icon={Bot} iconClass="text-amber-400" 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} /> <UpdateRow icon={Bot} iconClass="text-amber-400" name="Hermes-Agent" available={h?.update === true} busy={hermesUpdating} status={h?.update === true ? `Update: ${h.latest}` : h?.reachable === false ? "offline" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("hermes")} />
) )
})()} })()}
{updates?.model_list?.map((m) => ( {updates?.model_list?.map((m) => (
@@ -743,6 +760,119 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div> </div>
</div> </div>
{detail && (() => {
const d = detail.data
const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Engine (llama.cpp)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={() => setDetail(null)} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={() => setDetail(null)} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div>
) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
) : detail.kind === "os" ? (
(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren System ist aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
<div className="space-y-1">
{d!.packages!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
</span>
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
</span>
</div>
))}
</div>
</>
)
) : detail.kind === "engine" ? (
<>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
</div>
{(d?.name || d?.latest_tag) && (
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
)}
{d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
)}
</>
) : (
// hermes
(d?.commits?.length ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
<div className="space-y-1">
{d!.commits!.map((c) => (
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-[11px] truncate">{c.subject}</div>
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
</div>
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
</>
)
)}
</div>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={() => setDetail(null)} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={applyFromDetail} disabled={detail.loading || nothing}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
Jetzt aktualisieren
</button>
</div>
</div>
</div>
)
})()}
{dialog && ( {dialog && (
<CustomDialog <CustomDialog
type={dialog.type} type={dialog.type}
+19
View File
@@ -248,6 +248,25 @@ export interface UpdatesResp {
components?: ComponentUpdate[] components?: ComponentUpdate[]
} }
export interface UpdateDetails {
kind: "os" | "engine" | "hermes"
error?: string
// os
count?: number
packages?: { name: string; current: string; candidate: string }[]
// engine
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
name?: string | null
url?: string | null
body?: string | null
// hermes
branch?: string | null
behind?: number
commits?: { hash: string; subject: string; when: string }[]
}
export interface ConnectTool { export interface ConnectTool {
label: string label: string
lang: string lang: string