Files
mission-control-v2/frontend/src/components/SystemDrawer.tsx
T
Hitonabi 0c69c491e5 Single Source of Truth: Frontend/Backend-Ungereimtheiten ausgeraeumt (Review 15.07.)
Backend (Wahrheit reparieren):
- gateway_reachable() prueft jetzt den ECHTEN mc2-gateway (:9010 via V1_UPSTREAM)
  statt nur die Engine - toter Denkpfad war in Health/Cockpit unsichtbar
- /api/system/services vollstaendig: + LLM-Gateway, + Steuerpult, + Waechter
  (mc2-steward via is-active); scope-Feld (user/system); mc2-gateway/mc2-steward
  in die Restart-Allowlist
- Connect-Health Leitung 1 prueft den Client-Pfad (V1_UPSTREAM statt llama-swap)
- agent_status liefert pc_executor_url (UI zeigte hartcodierte IP/Ports)
- SSE-Endlosschleife gefixt: /api/auftragsbuch schrieb announce-branches bei JEDEM
  Aufruf neu -> mtime-Bump -> invalidate -> Refetch im 3-s-Takt je Client;
  jetzt nur noch bei echter Aenderung

Frontend (ein Datenweg):
- SystemDrawer komplett auf React-Query-Hooks (vorher eigenes 3-s-setInterval auf
  dieselben Endpunkte inkl. teurem Updates-Check); Dienste-Liste = Backend-Antwort
  (SERVICES-UI-Kopie geloescht); useDialog statt Eigenbau; fmtBytes statt Duplikat
- useLucyHealth: Dienst-Matching per systemd-unit statt Namensfragment; Gateway-
  Reparatur zielt auf mc2-gateway; neuer Waechter-Check; Backend-tot => ehrliches
  rotes Verdikt statt eingefrorener letzter Stand (auch Sidebar)
- Toter Code raus: views/models/Cockpit.tsx (908 Z.) + RoleAssignModal + Placeholder;
  LaneEditor (Routing-Policy) + WarmSetManager damit ZURUECK im UI als Tab
  "Routing & Warm-Set" im Modell-Manager
- Cockpit: blockierte Ideen-Karten erscheinen in "Braucht dich" + Kachel-Hint;
  Verbinden-Kachel zeigt 3 Leitungen live statt hartem "Zed"
- Maschinenraum: ehrliches "frei" (reale Belegung) + eigenes KV-Cache-Segment
- AgentView: Ports/PC-Adresse aus der API; Guide lehrt Lanes chat/coding
- queries.ts: useModels nutzt SSE-relax; Chronik-Limit im Query-Key;
  useJobs/useZeitmaschine mit enabled-Schalter

Verifiziert: tsc+vite gruen, Backend-Smoke beide Gateway-Modi, UI live gegen die
Box (Cockpit/Werkbank/Routing-Tab/Drawer rendern mit Echtdaten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 22:27:32 +02:00

576 lines
27 KiB
TypeScript

import { useEffect, useState } from "react"
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle, DownloadCloud } from "lucide-react"
import { api, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
import { useDialog } from "@/lib/useDialog"
import { useJobs, useServices, useUpdates, useZeitmaschine, useQueryClient, qk, invalidate } from "@/lib/queries"
import { fmtBytes } from "@/lib/format"
import { ServiceRow, UpdateRow } from "./system/rows"
import { LogConsole } from "./system/LogConsole"
import { SettingsTab } from "./system/SettingsTab"
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
// System-Schublade (Pflege/Logs/Einstellungen). Datenweg: AUSSCHLIESSLICH die zentralen
// React-Query-Hooks — früher pollte hier ein eigener setInterval alle 3 s dieselben
// Endpunkte (inkl. des teuren Updates-Checks) am Query-Layer vorbei, wodurch Cockpit
// und Schublade verschiedene Stände zeigen konnten (Review 15.07.). Die Dienste-Liste
// kommt 1:1 aus /api/system/services (unit + scope), keine UI-Kopie mehr.
interface SystemDrawerProps {
open: boolean
onClose: () => void
defaultTab?: "maintenance" | "logs" | "settings"
}
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
// Geteilte Caches; Jobs/Zeitmaschine beobachten nur bei offener Schublade.
const { data: updates } = useUpdates()
const { data: services } = useServices()
const jobs = useJobs(3_000, open).data ?? []
const { data: zeit } = useZeitmaschine(undefined, open)
const [selectedService, setSelectedService] = useState("llama-swap")
const [logs, setLogs] = useState("")
const [loadingLogs, setLoadingLogs] = useState(false)
const [logStatus, setLogStatus] = useState<string | null>(null)
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance")
const [checkingUpdates, setCheckingUpdates] = useState(false)
const [backupMsg, setBackupMsg] = useState("")
const [backupRunning, setBackupRunning] = useState(false)
const [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
const svcList = services?.services ?? []
const refreshJobs = () => invalidate(qc, qk.jobs)
function formatLastCheck(ts?: number | null) {
if (!ts) return "Nie"
return new Date(ts * 1000).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
})
}
useEffect(() => {
if (open && defaultTab) setActiveTab(defaultTab)
}, [open, defaultTab])
// Beim Öffnen einmal frisch ziehen — danach übernehmen die normalen Poll-Takte.
useEffect(() => {
if (open) invalidate(qc, qk.updates, qk.services, qk.jobs, qk.zeitmaschine)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
function loadServiceLogs(service: string) {
setLoadingLogs(true)
setLogStatus(null)
api<{ ok: boolean; text: string; err?: string; status?: string }>(`/api/maintenance/logs?service=${service}&lines=150`)
.then((res) => {
if (res.ok) {
setLogs(res.text)
} else {
setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`)
if (res.status === "incorrect_password" || res.status === "password_required") {
setLogStatus(res.status)
}
}
})
.catch((e) => setLogs(`Fehler: ${e.message}`))
.finally(() => setLoadingLogs(false))
}
// Load logs when tab is active or service changes
useEffect(() => {
if (!open || activeTab !== "logs") return
loadServiceLogs(selectedService)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeTab, selectedService])
// Backend-Wartungsriegel: lehnt ein zweites Update ab, solange eines läuft.
function handledBusy(res: any): boolean {
if (res?.status === "busy") {
showAlert("Update läuft bereits", `Es läuft gerade „${res.running}". Bitte warte, bis es fertig ist.`)
refreshJobs()
return true
}
return false
}
// Passwort-Fehler NICHT verschlucken: OS-Update braucht das Box-Passwort.
function handledSudo(res: any): boolean {
if (res?.status === "password_required" || res?.status === "incorrect_password") {
showAlert(
"Box-Passwort nötig",
(res.status === "incorrect_password"
? "Das gespeicherte Box-Passwort stimmt nicht. "
: "Für dieses Update braucht die Box dein Passwort. ")
+ "Bitte oben im Reiter „Einstellungen“ setzen bzw. korrigieren.",
)
return true
}
if (res && res.ok === false) {
showAlert("Update fehlgeschlagen", res.err || "Unbekannter Fehler.")
return true
}
return false
}
async function triggerUpdate(kind: "os" | "engine" | "swap") {
const path = { os: "os-update", engine: "engine-update", swap: "swap-update" }[kind]
try {
const res = await api<any>(`/api/maintenance/${path}`, { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Update konnte nicht starten: ${e.message}`)
}
}
async function doHermesUpdate() {
setHermesUpdating(true)
try {
const res = await api<any>("/api/maintenance/hermes-update", { method: "POST" })
if (handledBusy(res)) return
refreshJobs()
} catch (e: any) {
showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`)
} finally {
setHermesUpdating(false)
}
}
// Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde.
async function openUpdateDetails(kind: "os" | "engine" | "swap" | "hermes") {
setDetail({ kind, loading: true, data: null })
try {
const d = await api<UpdateDetails>(`/api/maintenance/update-details?kind=${kind}`)
setDetail({ kind, loading: false, data: d })
} catch (e: any) {
setDetail({ kind, loading: false, data: { kind, error: e.message } })
}
}
// Bestätigung aus dem Detail-Fenster → startet das passende Update.
function applyFromDetail() {
const kind = detail?.kind
setDetail(null)
if (kind === "os" || kind === "engine" || kind === "swap") triggerUpdate(kind)
else if (kind === "hermes") doHermesUpdate()
}
// „Alle aktualisieren": kettet alle AUSSTEHENDEN Updates in EINEM Wartungs-Job
// (Engine → Router → Hermes → OS; bei einem Fehler stoppt die Kette).
function triggerUpdateAll() {
const teile = [
updates?.engine ? "Engine" : null,
updates?.swap ? "Router" : null,
updates?.components?.some((c) => c.update === true) ? "Hermes-Agent" : null,
updates?.os ? `OS (${updates.os} Pakete)` : null,
].filter(Boolean)
showConfirm(
"Alle Updates einspielen?",
`Nacheinander in einem Job: ${teile.join(" → ")}. Jeder Schritt sichert und prüft sich selbst; schlägt einer fehl, stoppt der Rest. (OS braucht das Box-Passwort aus den Einstellungen — fehlt es, wird OS übersprungen.)`,
async () => {
try {
const res = await api<any>("/api/maintenance/update-all", { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
if (res?.status === "nothing") { showAlert("Nichts zu tun", "Es stehen keine Updates aus."); return }
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `„Alle aktualisieren" konnte nicht starten: ${e.message}`)
}
},
)
}
async function triggerCheckUpdates() {
setCheckingUpdates(true)
try {
await api("/api/maintenance/check-updates", { method: "POST" })
invalidate(qc, qk.jobs, qk.updates)
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`)
} finally {
setCheckingUpdates(false)
}
}
async function triggerModelUpgrade(repo: string, role: string) {
try {
await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, role }) })
showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`)
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`)
}
}
async function triggerReboot() {
showConfirm(
"Reboot bestätigen",
"Bist du sicher, dass du das gesamte Host-System neu starten willst?",
async () => {
try {
await api("/api/maintenance/reboot", { method: "POST" })
showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => onClose())
} catch (e: any) {
showAlert("Fehler", `Fehler beim Reboot: ${e.message}`)
}
}
)
}
async function doBackup() {
setBackupRunning(true)
setBackupMsg("Snapshot wird erzeugt...")
try {
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} Komponenten)` : "Backup fehlgeschlagen.")
invalidate(qc, qk.zeitmaschine)
} catch (e: any) {
setBackupMsg(`Fehler: ${e.message}`)
} finally {
setBackupRunning(false)
}
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
try {
const res = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: serviceId })
})
if (res.ok) {
showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => {
if (activeTab === "logs" && selectedService === serviceId) {
loadServiceLogs(serviceId)
}
})
invalidate(qc, qk.services, qk.health, qk.models)
} else {
showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
showAlert("Fehler", `Fehler beim Neustart: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
}
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
refreshJobs()
} catch (e: any) {
showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`)
}
}
// Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar.
const maintenanceJob = jobs.find(j => (j.state === "running" || j.state === "queued") && j.group === "maintenance")
// Wie viele Update-ARTEN ausstehen (für den „Alle aktualisieren"-Button).
const pendingAll =
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine ? 1 : 0) + (updates.swap ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
const backups = zeit?.backups ?? []
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 bg-black/60 backdrop-blur-sm z-50 transition-opacity duration-300",
open ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
/>
{/* Drawer */}
<div
className={cn(
"fixed inset-y-0 right-0 w-full sm:w-[640px] bg-card/85 backdrop-blur-xl border-l border-border/60 shadow-2xl z-50 flex flex-col transform transition-transform duration-300 ease-in-out font-sans text-foreground",
open ? "translate-x-0" : "translate-x-full"
)}
>
{/* Header */}
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/40 px-6">
<div className="flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase font-space">OS-Zentrale & Pflege</h2>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border/40 px-6">
{(["maintenance", "logs", "settings"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === tab
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "maintenance" ? "System-Wartung" : tab === "logs" ? "System-Logs" : "Einstellungen"}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" && (
<>
{/* Updates */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Updates</h3>
<div className="flex items-center gap-3">
{pendingAll > 0 && (
<button onClick={triggerUpdateAll} disabled={!!maintenanceJob}
className="flex items-center gap-1 rounded-md border border-primary/40 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary transition-colors hover:bg-primary/20 disabled:opacity-50">
<DownloadCloud className="h-3 w-3" /> Alle aktualisieren ({pendingAll})
</button>
)}
<button onClick={triggerCheckUpdates} disabled={checkingUpdates || !!maintenanceJob}
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>
{updates?.last_check && (
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
)}
{maintenanceJob && (
<div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
<span>Update läuft: <span className="font-semibold">{maintenanceJob.label}</span> bitte warten. Weitere Updates sind solange gesperrt.</span>
</div>
)}
<div className="space-y-1.5">
<UpdateRow icon={Shield} iconClass="text-cyan-400" name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("os")} />
<UpdateRow icon={Server} iconClass="text-violet-400" name="Inferenz-Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("engine")} />
<UpdateRow icon={Shuffle} iconClass="text-fuchsia-400" name="Router (llama-swap)" available={!!updates?.swap} status={updates?.swap ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("swap")} />
{(() => {
const h = updates?.components?.find((c) => c.key === "hermes_agent")
return (
<UpdateRow icon={Bot} iconClass="text-amber-400" name="Hermes-Agent" available={h?.update === true} busy={hermesUpdating} status={h?.update === true ? `Update: ${h.latest}` : h?.reachable === false ? "offline" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("hermes")} />
)
})()}
{updates?.model_list?.map((m) => (
<UpdateRow key={m.role} icon={Box} iconClass="text-emerald-400" name={`Modell · ${m.role}`} available={true} status={m.title} actionLabel="Upgrade" onAction={() => triggerModelUpgrade(m.repo, m.role)} />
))}
</div>
</div>
{/* Dienste — 1:1 die Backend-Liste (inkl. LLM-Gateway + Wächter seit UMBAU v3) */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Dienste</h3>
<div className="space-y-1.5">
{svcList.length === 0 && (
<div className="rounded-lg border border-dashed border-border/50 p-4 text-center text-xs text-muted-foreground">
Dienste-Liste wird geladen
</div>
)}
{svcList.map((s) => (
<ServiceRow key={s.unit} label={s.name} system={s.scope === "system"} ok={s.ok}
busy={restartingServices[s.unit]}
onRestart={() => restartService(s.unit)}
onLogs={() => { setSelectedService(s.unit); setActiveTab("logs") }} />
))}
</div>
</div>
{/* Backup — Liste kommt aus derselben Quelle wie die Zeitmaschine (Chronik) */}
<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 · Wiederherstellen: Chronik Zeitmaschine</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" />
<div>
<div>Host-System neu starten</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet die ganze Box neu</div>
</div>
</button>
</div>
{/* Active Jobs */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Hintergrund-Aufgaben</h3>
<span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded-full">
{jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv
</span>
</div>
<div className="space-y-3">
{jobs.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-xl p-6 text-center">
Aktuell keine aktiven Hintergrund-Jobs.
</div>
) : (
jobs.map((job) => {
const isActive = job.state === "running" || job.state === "queued"
return (
<div
key={job.id}
className={cn(
"p-3 rounded-xl border transition-all duration-300",
isActive
? "border-primary/40 bg-primary/5 shadow-md shadow-primary/5"
: "border-border/40 bg-background/20 opacity-80"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-xs font-semibold flex items-center gap-1.5">
{isActive && (
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
)}
{job.label}
</div>
<div className="text-[10px] font-mono text-muted-foreground uppercase flex items-center gap-2">
<span>ID: {job.id}</span>
<span></span>
<span className={cn(
job.state === "done" && "text-emerald-400",
job.state === "failed" && "text-red-400",
job.state === "running" && "text-primary",
job.state === "queued" && "text-amber-400",
job.state === "canceled" && "text-muted-foreground"
)}>
{job.state}
</span>
</div>
</div>
{isActive && (
<button
onClick={() => cancelJob(job.id)}
className="text-[10px] font-semibold text-red-400 hover:text-red-300 border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 px-2 py-0.5 rounded transition-colors"
>
Abbrechen
</button>
)}
</div>
{/* Progress */}
{job.state === "running" && (
<div className="mt-3 space-y-1">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${job.progress ?? 0}%` }}
/>
</div>
<div className="flex justify-between items-center text-[9px] font-mono text-muted-foreground">
<span>{job.progress ?? 0}%</span>
{job.done_bytes != null && job.total_bytes != null && (
<span>
{fmtBytes(job.done_bytes)} / {fmtBytes(job.total_bytes)}
{job.rate_bps != null && ` (${fmtBytes(job.rate_bps)}/s)`}
</span>
)}
{job.eta_s != null && <span>ETA: {job.eta_s}s</span>}
</div>
</div>
)}
</div>
)
})
)}
</div>
</div>
</>
)}
{activeTab === "logs" && (
// Logs View
<div className="flex flex-col h-full space-y-4">
{/* Service Select & Restart — dieselbe Backend-Liste wie die Wartungs-Ansicht */}
<div className="flex items-center gap-2">
<select
value={selectedService}
onChange={(e) => setSelectedService(e.target.value)}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none text-foreground font-semibold"
>
{svcList.map((s) => (
<option key={s.unit} value={s.unit}>
{s.name} ({s.scope === "system" ? "systemd-root" : "user"})
</option>
))}
</select>
<button
onClick={() => restartService(selectedService)}
disabled={restartingServices[selectedService]}
className="flex h-9 px-3 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-background/20 hover:border-primary/50 transition-colors disabled:opacity-50"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restartingServices[selectedService] && "animate-spin")} />
Restart
</button>
</div>
<LogConsole
service={selectedService}
text={logs}
loading={loadingLogs}
logStatus={logStatus}
onRefresh={() => loadServiceLogs(selectedService)}
onOpenSettings={() => setActiveTab("settings")}
/>
</div>
)}
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
</div>
</div>
{detail && (
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
onClose={() => setDetail(null)} onApply={applyFromDetail} />
)}
{dialogElement}
</>
)
}