Feat: Agent-Hirn bleibt warm (Re-Warm-Waechter) + Updates-Karte konsolidiert

Punkt 1 - Hirn warm: brains-Gruppe wird bei on-demand-Last ausserhalb der Gruppe
verdraengt; persist verhindert nur Idle-Unload, nicht Gruppen-Swap -> Hirn blieb bis zum
naechsten llama-swap-Neustart kalt. Neu: services/warmer.py als Hintergrund-Task (FastAPI
lifespan) prueft periodisch llama-swap /running; ist die Box idle, pingt es das Hirn
(Rolle hermes) vor. Waehrend aktiver Last (irgendwas geladen) haelt es sich raus.
Justierbar via MC_REWARM_* (ENABLED/INTERVAL/MODEL). Kein sudo, im Repo, deployt normal.

Punkt 2 - Updates-Doppelung: Aktionen gab es auf der Karte UND im Pflege-Drawer.
UpdatesCard zeigt jetzt nur noch die Status-Ampel + 'Updates verwalten & Pflege'-Button
(oeffnet den Drawer). Alle Aktionen (OS/Engine/Reboot/Modell-Upgrade) leben im Drawer mit
Job-Fortschritt -> keine Dublette, kuerzere Karte, Sudo-Modal raus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 17:12:49 +02:00
parent 89cdc60b6e
commit 694aff9801
7 changed files with 235 additions and 426 deletions
+44 -307
View File
@@ -1,333 +1,70 @@
import { useState } from "react"
import { ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
import { api } from "@/lib/api"
import { useUpdates, useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { ShieldAlert, ChevronRight } from "lucide-react"
import { useUpdates } from "@/lib/queries"
import { cn } from "@/lib/utils"
function StatusRow({ label, value, tone }: { label: React.ReactNode; value: string; tone: "alert" | "accent" | "muted" }) {
return (
<div className={cn(
"flex items-center justify-between rounded-lg border px-3 py-1.5 text-xs",
tone === "alert" ? "border-amber-500/30 bg-amber-500/5 font-semibold text-amber-400"
: tone === "accent" ? "border-primary/30 bg-primary/5 font-semibold text-primary"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span className="flex items-center gap-1.5">{label}</span>
<span className="font-mono text-[10px]">{value}</span>
</div>
)
}
export function UpdatesCard() {
const qc = useQueryClient()
const { data: updates } = useUpdates(3_000)
const { data: jobs = [] } = useJobs(3_000)
const { showConfirm, dialogElement } = useDialog()
const [msg, setMsg] = useState("")
const [loading, setLoading] = useState(false)
const [sudoPassword, setSudoPassword] = useState("")
const [sudoLoading, setSudoLoading] = useState(false)
const [sudoModal, setSudoModal] = useState<{
open: boolean
actionPath: string
actionLabel: string
payload?: any
error?: string
}>({ open: false, actionPath: "", actionLabel: "" })
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.updates })
qc.invalidateQueries({ queryKey: qk.jobs })
qc.invalidateQueries({ queryKey: qk.models })
}
async function postAction(path: string, label: string, payload?: any, password?: string) {
setMsg(`${label} wird ausgeführt...`)
setLoading(true)
try {
const body: any = { ...payload }
if (password) body.sudo_password = password
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal({
open: true,
actionPath: path,
actionLabel: label,
payload,
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
})
setMsg("")
return
}
if (r.job_id) setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
else if (r.ok) setMsg(`${label} erfolgreich ausgeführt.`)
else setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
refresh()
} catch (e: any) {
setMsg(`Fehler bei ${label}: ${e.message}`)
} finally {
setLoading(false)
}
}
async function handleSudoSubmit() {
setSudoLoading(true)
try {
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal(prev => ({ ...prev, error: "Falsches Sudo-Passwort. Bitte erneut versuchen." }))
return
}
if (r.job_id) setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
else if (r.ok) setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
else setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
refresh()
} catch (e: any) {
setMsg(`Fehler: ${e.message}`)
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
} finally {
setSudoLoading(false)
}
}
async function upgradeModel(repo: string, role: string) {
setMsg(`Upgrade für ${repo} wird gestartet...`)
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
})
setMsg(`Upgrade-Download gestartet.`)
refresh()
} catch (e: any) {
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
}
}
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
const openDrawer = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
{/* Sudo Password Dialog Modal */}
{sudoModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
<button
onClick={() => { setSudoModal({ open: false, actionPath: "", actionLabel: "" }); setSudoPassword("") }}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
</p>
<div className="space-y-2">
<input
type="password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
placeholder="Sudo-Passwort eingeben..."
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
autoFocus
/>
{sudoModal.error && (
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => { setSudoModal({ open: false, actionPath: "", actionLabel: "" }); setSudoPassword("") }}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
>
Abbrechen
</button>
<button
onClick={handleSudoSubmit}
disabled={!sudoPassword || sudoLoading}
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
>
{sudoLoading ? "Prüfe..." : "Ausführen"}
</button>
</div>
</div>
</div>
)}
<div className="flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2 border-b border-border/20 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/20 pb-2">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates &amp; Pflege</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Updates &amp; Pflege</h2>
</div>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground/80 font-mono">
<span className="font-mono text-[9px] text-muted-foreground/80">
Zuletzt gesucht: {new Date(updates.last_check * 1000).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit"
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</span>
)}
</div>
{updates ? (
<div className="space-y-3">
<div className="space-y-1.5">
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.os > 0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>OS-Pakete</span>
<span className="font-mono">{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}</span>
</div>
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.engine > 0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>Engine (llama.cpp)</span>
<span className="font-mono">{updates.engine > 0 ? "Update verfügbar" : "aktuell"}</span>
</div>
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.models > 0
? "border-primary/30 bg-primary/5 text-primary font-semibold animate-pulse"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>Modell-Upgrades</span>
<span className="font-mono">{updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"}</span>
</div>
{/* Komponenten: Hermes Agent + AnythingLLM */}
{updates.components?.map((c) => {
const isUpdate = c.update === true
return (
<div key={c.key} className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
isUpdate
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span className="flex items-center gap-1.5">
{c.name}
{c.reachable === false && (
<span className="text-[8px] font-bold uppercase text-red-400/80">offline</span>
)}
</span>
<span className="font-mono text-[10px]" title={c.current ? `installiert: ${c.current}` : undefined}>
{isUpdate
? `Update: ${c.latest}`
: c.update === false
? "aktuell"
: c.latest
? `neueste: ${c.latest}`
: "—"}
</span>
</div>
)
})}
</div>
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
<button
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
disabled={loading || !!activeOsJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeOsJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
</>
) : (
<span>OS Update</span>
)}
</button>
<button
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
disabled={loading || !!activeEngineJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeEngineJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
</>
) : (
<span>Engine Update</span>
)}
</button>
</div>
<button
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
disabled={loading}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
<Power className="h-3.5 w-3.5" />
<span>Host Reboot</span>
</button>
{updates.model_list.length > 0 && (
<div className="space-y-1.5 border-t border-border/20 pt-3">
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
<div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
{updates.model_list.map((m) => (
<div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
<span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
</span>
<button
onClick={() => upgradeModel(m.repo, m.role)}
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
>
<Download className="h-2.5 w-2.5" /> Laden
</button>
</div>
))}
</div>
</div>
)}
<div className="space-y-1.5">
<StatusRow label="OS-Pakete" tone={updates.os > 0 ? "alert" : "muted"}
value={updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"} />
<StatusRow label="Engine (llama.cpp)" tone={updates.engine > 0 ? "alert" : "muted"}
value={updates.engine > 0 ? "Update verfügbar" : "aktuell"} />
<StatusRow label="Modell-Upgrades" tone={updates.models > 0 ? "accent" : "muted"}
value={updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"} />
{updates.components?.map((c) => (
<StatusRow
key={c.key}
tone={c.update === true ? "alert" : "muted"}
label={<>{c.name}{c.reachable === false && <span className="text-[8px] font-bold uppercase text-red-400/80">offline</span>}</>}
value={c.update === true ? `Update: ${c.latest}` : c.update === false ? "aktuell" : c.latest ? `neueste: ${c.latest}` : "—"}
/>
))}
</div>
) : (
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
)}
{msg && (
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
{msg}
</div>
)}
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
</div>
</div>
<div className="mt-4 border-t border-border/30 pt-3 shrink-0">
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer"))}
className="w-full h-8 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 shadow-md shadow-primary/10"
>
System-Zentrale öffnen
</button>
</div>
{dialogElement}
<button
onClick={openDrawer}
className="mt-4 flex h-9 w-full items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow-md shadow-primary/10 transition-all hover:opacity-90 cursor-pointer"
>
Updates verwalten &amp; Pflege <ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)
}