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:
+18
-1
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+115
-115
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||||
|
|||||||
@@ -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 & Pflege</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Updates & 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 & Pflege <ChevronRight className="h-3.5 w-3.5" />
|
||||||
System-Zentrale öffnen
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dialogElement}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user