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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -17,6 +19,7 @@ from starlette.requests import Request
|
||||
|
||||
from config import FRONTEND_DIST, VERSION
|
||||
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
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
@@ -24,8 +27,22 @@ logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
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.
|
||||
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="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>
|
||||
|
||||
@@ -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 & Pflege</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Updates & 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>
|
||||
<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>
|
||||
)}
|
||||
</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"
|
||||
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"
|
||||
>
|
||||
System-Zentrale öffnen
|
||||
Updates verwalten & Pflege <ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user