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
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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-iQ69359J.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3BExjP9.css">
<script type="module" crossorigin src="/assets/index-DGxLseeM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DA_8pSCQ.css">
</head>
<body>
<div id="root"></div>
+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>
)
}