Feat: System-Wartung-Rework + Hermes-Update-Button

Wartungs-Tab im SystemDrawer neu strukturiert:
- UPDATES: eine einheitliche Liste (OS, Engine, Hermes-Agent, Modell-Upgrades) mit
  Status + Aktion-Button, der nur aktiv ist wenn ein Update ansteht (statt Klick-Karten,
  die sofort updaten). Konsistent mit der Dashboard-UpdatesCard.
- DIENSTE: neue Sektion, alle systemd-Units mit Status-Punkt (aus /api/system/services,
  jetzt inkl. mem0-service) + Restart + Logs-Sprung.
- BACKUP: kompakt (letztes Backup + Snapshot-Button + Restore-Hinweis).
- GEFAHRENZONE: Reboot abgetrennt. Jobs nur wenn vorhanden.

Hermes-Update-Button: POST /api/maintenance/hermes-update -> Job (Backup -> `hermes update
--yes` (git pull + deps) -> hermes-gateway restart). Backend: hermes_update_job + USER_SERVICES
um mem0-service/hermes-terminal ergaenzt (Restart ging vorher nicht), mem0 in services-API.

Live verifiziert: Button hat Hermes d470ed0 -> 3b44a3c aktualisiert, Integration intakt
(memory.provider, Plugin laedt), Pre-Update-Backup angelegt, Drawer rendert fehlerfrei.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 21:59:04 +02:00
parent b38e3360c5
commit 58dda66f84
10 changed files with 681 additions and 611 deletions
+5
View File
@@ -45,6 +45,11 @@ def engine_update(body: SudoReq) -> dict:
return res return res
@router.post("/maintenance/hermes-update")
def hermes_update() -> dict:
return maintenance.hermes_update_job()
@router.post("/maintenance/reboot") @router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict: def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password) return maintenance.reboot(body.sudo_password)
+11 -1
View File
@@ -12,7 +12,9 @@ import subprocess
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL
from services import backup as backup_svc from services import backup as backup_svc
from services.agent import agent_status from services.agent import agent_status
from services.gateway import gateway_reachable from services.gateway import gateway_reachable
@@ -36,6 +38,13 @@ def status() -> dict:
return system_status() return system_status()
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
@router.get("/system/services") @router.get("/system/services")
def services() -> dict: def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige).""" """Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
@@ -47,6 +56,7 @@ def services() -> dict:
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()}, {"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]}, {"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]}, {"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
{"name": "Mem0 (Gedächtnis)", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
], ],
"links": { "links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui", "engine_ui": f"{LLAMA_SWAP_URL}/ui",
+20 -1
View File
@@ -19,7 +19,7 @@ from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"} SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-dashboard", "hermes-webui"} USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-terminal", "mem0-service"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root). # Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -346,6 +346,25 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def hermes_update_job() -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
git = system.find_hermes_agent_git()
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
py = os.path.join(path, "venv", "bin", "python")
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
cmd = (f"bash {backup} || true; "
f"cd {path} && {py} -m hermes_cli.main update --yes "
f"&& systemctl --user restart hermes-gateway")
def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", cmd], "Hermes-Agent-Update", on_done=on_done)
return {"ok": True, "job_id": job_id}
def reboot(sudo_password: str | None = None) -> dict: def reboot(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password(sudo_password):
return err return err
@@ -1,4 +1,4 @@
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-DkZXHl-q.js";/** import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-cq5kdO0r.js";/**
* @license * @license
* Copyright 2010-2023 Three.js Authors * Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT * SPDX-License-Identifier: MIT
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
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-DkZXHl-q.js"></script> <script type="module" crossorigin src="/assets/index-cq5kdO0r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qbOD334z.css"> <link rel="stylesheet" crossorigin href="/assets/index-3d2FUwyu.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+137 -111
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle, Save } from "lucide-react" import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api" import { api, type Job, type UpdatesResp, type ServicesResp } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog" import { CustomDialog } from "./CustomDialog"
@@ -11,13 +11,53 @@ interface SystemDrawerProps {
defaultTab?: "maintenance" | "logs" | "settings" defaultTab?: "maintenance" | "logs" | "settings"
} }
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
const SERVICES = [ const SERVICES = [
{ id: "llama-swap", label: "Llama Swap", type: "system" }, { id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "mission-control-2", label: "Mission Control 2", type: "user" }, { id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user" }, { id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user" } { id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
] ]
function UpdateRow({ icon: Icon, name, status, available, busy, actionLabel, onAction }: {
icon: any; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", available ? "text-amber-400" : "text-muted-foreground")} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
function formatBytes(bytes?: number) { function formatBytes(bytes?: number) {
if (bytes == null) return "" if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB` if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
@@ -36,6 +76,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [checkingUpdates, setCheckingUpdates] = useState(false) const [checkingUpdates, setCheckingUpdates] = useState(false)
const [backupMsg, setBackupMsg] = useState("") const [backupMsg, setBackupMsg] = useState("")
const [backupRunning, setBackupRunning] = useState(false) const [backupRunning, setBackupRunning] = useState(false)
const [services, setServices] = useState<ServicesResp | null>(null)
const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([])
const [hermesUpdating, setHermesUpdating] = useState(false)
// Custom Dialog State // Custom Dialog State
const [dialog, setDialog] = useState<{ const [dialog, setDialog] = useState<{
@@ -115,6 +158,15 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
.catch((e) => console.error("Error loading jobs", e)) .catch((e) => console.error("Error loading jobs", e))
} }
function loadServices() {
api<ServicesResp>("/api/system/services").then(setServices).catch(() => {})
}
function loadBackups() {
api<{ backups: { snapshot: string; size_mb?: number }[] }>("/api/system/backups")
.then((d) => setBackups(d.backups || [])).catch(() => {})
}
function loadServiceLogs(service: string) { function loadServiceLogs(service: string) {
setLoadingLogs(true) setLoadingLogs(true)
setLogStatus(null) setLogStatus(null)
@@ -146,9 +198,12 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
if (!open) return if (!open) return
loadUpdates() loadUpdates()
loadJobs() loadJobs()
loadServices()
loadBackups()
const timer = setInterval(() => { const timer = setInterval(() => {
loadJobs() loadJobs()
loadUpdates() loadUpdates()
loadServices()
}, 3000) }, 3000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [open]) }, [open])
@@ -179,6 +234,24 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
} }
} }
function triggerHermesUpdate() {
showConfirm(
"Hermes-Agent aktualisieren",
"Zieht die neuesten Änderungen aus git, installiert Abhängigkeiten neu und startet den Hermes-Gateway neu (vorher automatisches Backup). Fortschritt unter Hintergrund-Aufgaben.",
async () => {
setHermesUpdating(true)
try {
await api<{ job_id: string }>("/api/maintenance/hermes-update", { method: "POST" })
loadJobs()
} catch (e: any) {
showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`)
} finally {
setHermesUpdating(false)
}
},
)
}
async function triggerCheckUpdates() { async function triggerCheckUpdates() {
setCheckingUpdates(true) setCheckingUpdates(true)
try { try {
@@ -228,7 +301,8 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
setBackupMsg("Snapshot wird erzeugt...") setBackupMsg("Snapshot wird erzeugt...")
try { try {
const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" }) const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" })
setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Dateien)` : "Keine Änderungen zu sichern.") setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Komponenten)` : "Backup fehlgeschlagen.")
loadBackups()
} catch (e: any) { } catch (e: any) {
setBackupMsg(`Fehler: ${e.message}`) setBackupMsg(`Fehler: ${e.message}`)
} finally { } finally {
@@ -341,120 +415,72 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<div className="flex-1 overflow-y-auto p-6 space-y-6"> <div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" && ( {activeTab === "maintenance" && (
<> <>
{/* Quick Actions */} {/* Updates */}
<div className="space-y-3"> <div className="space-y-2.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Updates</h3>
<div className="flex items-center gap-2"> <button onClick={triggerCheckUpdates} disabled={checkingUpdates}
{updates?.last_check && ( className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50">
<span className="text-[9px] text-muted-foreground"> <RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} /> Nach Updates suchen
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
<button
onClick={triggerCheckUpdates}
disabled={checkingUpdates}
className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50"
>
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} />
Nach Updates suchen
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={triggerOsUpdate}
className="flex flex-col items-start gap-1 p-4 rounded-xl border border-border/60 bg-background/20 hover:border-primary/50 transition-all text-left group"
>
<Shield className="h-5 w-5 text-cyan-400 mb-1 group-hover:scale-105 transition-transform" />
<span className="text-xs font-semibold">OS Update (apt)</span>
<span className="text-[10px] text-muted-foreground">
{updates?.os ? `${updates.os} Updates verfügbar` : "Auf neuestem Stand"}
</span>
</button>
<button
onClick={triggerEngineUpdate}
className="flex flex-col items-start gap-1 p-4 rounded-xl border border-border/60 bg-background/20 hover:border-primary/50 transition-all text-left group"
>
<Server className="h-5 w-5 text-violet-400 mb-1 group-hover:scale-105 transition-transform" />
<span className="text-xs font-semibold">Engine Update</span>
<span className="text-[10px] text-muted-foreground">
{updates?.engine ? "Update verfügbar" : "Auf neuestem Stand"}
</span>
</button> </button>
</div> </div>
{updates?.last_check && (
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
)}
<div className="space-y-1.5">
<UpdateRow icon={Shield} name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Aktualisieren" onAction={triggerOsUpdate} />
<UpdateRow icon={Server} name="Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Aktualisieren" onAction={triggerEngineUpdate} />
{(() => {
const h = updates?.components?.find((c) => c.key === "hermes_agent")
return (
<UpdateRow icon={Bot} name="Hermes-Agent" available={h?.update === true} busy={hermesUpdating} status={h?.update === true ? `Update: ${h.latest}` : h?.reachable === false ? "offline" : "aktuell"} actionLabel="Aktualisieren" onAction={triggerHermesUpdate} />
)
})()}
{updates?.model_list?.map((m) => (
<UpdateRow key={m.role} icon={Box} name={`Modell · ${m.role}`} available={true} status={m.title} actionLabel="Upgrade" onAction={() => triggerModelUpgrade(m.repo, m.role)} />
))}
</div>
</div>
<button {/* Dienste */}
onClick={triggerReboot} <div className="space-y-2.5">
className="flex w-full items-center gap-3 p-3 rounded-xl border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold" <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Dienste</h3>
> <div className="space-y-1.5">
{SERVICES.map((s) => (
<ServiceRow key={s.id} label={s.label} system={s.type === "system"} ok={services?.services.find((x) => x.name.toLowerCase().includes(s.reach))?.ok} busy={restartingServices[s.id]} onRestart={() => restartService(s.id)} onLogs={() => { setSelectedService(s.id); setActiveTab("logs") }} />
))}
</div>
</div>
{/* Backup */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Backup</h3>
<div className="rounded-lg border border-border/50 bg-background/20 px-3 py-2.5 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}</div>
<div className="text-[10px] text-muted-foreground">{backups.length} Snapshots · Restore per CLI (restore.sh)</div>
</div>
<button onClick={doBackup} disabled={backupRunning} className="flex items-center gap-1.5 text-[11px] font-semibold rounded-lg px-2.5 py-1 bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50 shrink-0">
<Camera className={cn("h-3.5 w-3.5", backupRunning && "animate-pulse")} /> Snapshot
</button>
</div>
{backupMsg && (
<div className="text-[10px] font-mono text-primary bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">{backupMsg}</div>
)}
</div>
{/* Gefahrenzone */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-red-400/80">Gefahrenzone</h3>
<button onClick={triggerReboot} className="flex w-full items-center gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold">
<Power className="h-4.5 w-4.5" /> <Power className="h-4.5 w-4.5" />
<div> <div>
<div>Host-System neu starten (Reboot)</div> <div>Host-System neu starten</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet das gesamte Betriebssystem des Homelabs neu</div> <div className="text-[10px] text-red-400/80 font-normal">Startet die ganze Box neu</div>
</div> </div>
</button> </button>
</div> </div>
{/* Backup / Snapshot */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">System-Backup &amp; Snapshot</h3>
<div className="p-4 rounded-xl border border-border/60 bg-background/20 space-y-3">
<p className="text-[10px] text-muted-foreground leading-normal">
Voll-Backup: Gedächtnis (Mem0), Hermes-Config &amp; Secrets, llama-swap-Config.
Läuft auch täglich automatisch. Wiederherstellen per <code>deploy/restore.sh</code> (siehe docs/BACKUP.md).
</p>
<button
onClick={doBackup}
disabled={backupRunning}
className="w-full h-9 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 disabled:opacity-50 shadow-md shadow-primary/10"
>
<Save className={cn("h-4 w-4", backupRunning && "animate-pulse")} /> Snapshot erstellen
</button>
{backupMsg && (
<div className="text-[10px] font-mono text-primary bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
{backupMsg}
</div>
)}
</div>
</div>
{/* Modell Upgrades */}
{updates?.model_list && updates.model_list.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Upgrades</h3>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
</div>
<div className="space-y-2">
{updates.model_list.map((m) => (
<div key={m.role} className="p-3 rounded-xl border border-border/60 bg-background/20 flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold">{m.title}</div>
<div className="text-[10px] font-mono text-muted-foreground">{m.repo}</div>
<div className="text-[10px] text-primary font-semibold uppercase mt-0.5">Rolle: {m.role}</div>
</div>
<button
onClick={() => triggerModelUpgrade(m.repo, m.role)}
className="flex items-center gap-1.5 text-[10px] font-semibold text-emerald-400 hover:text-emerald-300 border border-emerald-500/20 bg-emerald-500/5 hover:bg-emerald-500/10 px-2 py-1 rounded-lg transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
Upgrade
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Active Jobs */} {/* Active Jobs */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">