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
+151 -21
View File
@@ -1,6 +1,6 @@
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 { api, type Job, type UpdatesResp, type ServicesResp } from "@/lib/api"
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, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
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 [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
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
@@ -234,22 +237,36 @@ 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 doHermesUpdate() {
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)
}
}
// Ö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() {
@@ -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="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={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={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="Anzeigen" onAction={() => openUpdateDetails("engine")} />
{(() => {
const h = updates?.components?.find((c) => c.key === "hermes_agent")
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) => (
@@ -743,6 +760,119 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</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 && (
<CustomDialog
type={dialog.type}
+19
View File
@@ -248,6 +248,25 @@ export interface UpdatesResp {
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 {
label: string
lang: string