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
+18 -1
View File
@@ -6,8 +6,10 @@ setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen. Server (proxyt /api hierher), daher CORS für localhost offen.
""" """
import asyncio
import logging import logging
import os import os
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -17,6 +19,7 @@ from starlette.requests import Request
from config import FRONTEND_DIST, VERSION from config import FRONTEND_DIST, VERSION
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system
from services import warmer
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # für alle Module (logging.getLogger(__name__)).
@@ -24,8 +27,22 @@ logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
) )
log = logging.getLogger(__name__)
app = FastAPI(title="Mission Control 2.0", version=VERSION) @asynccontextmanager
async def lifespan(app: FastAPI):
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn."""
task = asyncio.create_task(warmer.rewarm_loop()) if warmer.ENABLED else None
if task:
log.info("Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Modell '%s')", warmer.INTERVAL, warmer.MODEL)
try:
yield
finally:
if task:
task.cancel()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware( app.add_middleware(
+55
View File
@@ -0,0 +1,55 @@
"""
Hält das Agent-Hirn (Rolle `hermes`) dauerhaft warm.
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
der `brains`-Gruppe, wird die ganze Gruppe (inkl. Hirn) verdrängt. `persist: true`
verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; neu vorgewärmt wird sonst erst
beim nächsten llama-swap-(Re)Start. Dieser Wächter schließt die Lücke: ist die Box idle
(nichts geladen), pingt er das Hirn vor. Während aktiver Last (irgendetwas geladen) hält
er sich raus, verdrängt also nie ein gerade genutztes Modell.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL, MC_REWARM_MODEL.
"""
import asyncio
import logging
import os
import httpx
from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
MODEL = os.environ.get("MC_REWARM_MODEL", "hermes") # Alias der Rolle
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
async def _running_empty() -> bool:
async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {}
return not (data.get("running") or [])
async def _warm() -> None:
async with httpx.AsyncClient(timeout=180.0) as c:
await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": MODEL, "max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
})
async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch, wärmt bei Idle vor."""
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True:
try:
if await _running_empty():
log.info("rewarm: Box idle → Hirn '%s' wird vorgewärmt", MODEL)
await _warm()
except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
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-iQ69359J.js"></script> <script type="module" crossorigin src="/assets/index-DGxLseeM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3BExjP9.css"> <link rel="stylesheet" crossorigin href="/assets/index-DA_8pSCQ.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+44 -307
View File
@@ -1,333 +1,70 @@
import { useState } from "react" import { ShieldAlert, ChevronRight } from "lucide-react"
import { ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react" import { useUpdates } from "@/lib/queries"
import { api } from "@/lib/api"
import { useUpdates, useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils" 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() { export function UpdatesCard() {
const qc = useQueryClient()
const { data: updates } = useUpdates(3_000) const { data: updates } = useUpdates(3_000)
const { data: jobs = [] } = useJobs(3_000) const openDrawer = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
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"))
return ( 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"> <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">
{/* 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="space-y-4"> <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"> <div className="flex items-center gap-2">
<ShieldAlert className="h-4.5 w-4.5 text-primary" /> <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> </div>
{updates?.last_check && ( {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", { 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> </span>
)} )}
</div> </div>
{updates ? ( {updates ? (
<div className="space-y-3"> <div className="space-y-1.5">
<div className="space-y-1.5"> <StatusRow label="OS-Pakete" tone={updates.os > 0 ? "alert" : "muted"}
<div className={cn( value={updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"} />
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs", <StatusRow label="Engine (llama.cpp)" tone={updates.engine > 0 ? "alert" : "muted"}
updates.os > 0 value={updates.engine > 0 ? "Update verfügbar" : "aktuell"} />
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold" <StatusRow label="Modell-Upgrades" tone={updates.models > 0 ? "accent" : "muted"}
: "border-border/30 bg-background/25 text-muted-foreground" value={updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"} />
)}> {updates.components?.map((c) => (
<span>OS-Pakete</span> <StatusRow
<span className="font-mono">{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}</span> key={c.key}
</div> 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>}</>}
<div className={cn( value={c.update === true ? `Update: ${c.latest}` : c.update === false ? "aktuell" : c.latest ? `neueste: ${c.latest}` : "—"}
"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> </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>
<div className="mt-4 border-t border-border/30 pt-3 shrink-0"> <button
<button onClick={openDrawer}
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer"))} 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"
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" >
> Updates verwalten &amp; Pflege <ChevronRight className="h-3.5 w-3.5" />
System-Zentrale öffnen </button>
</button>
</div>
{dialogElement}
</div> </div>
) )
} }