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(null) const [restartingServices, setRestartingServices] = useState>({}) 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(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(`/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("/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(`/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("/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 */}
{/* Drawer */}
{/* Header */}

OS-Zentrale & Pflege

{/* Navigation Tabs */}
{(["maintenance", "logs", "settings"] as const).map((tab) => ( ))}
{/* Content */}
{activeTab === "maintenance" && ( <> {/* Updates */}

Updates

{pendingAll > 0 && ( )}
{updates?.last_check && (
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
)} {maintenanceJob && (
Update läuft: {maintenanceJob.label} — bitte warten. Weitere Updates sind solange gesperrt.
)}
openUpdateDetails("os")} /> openUpdateDetails("engine")} /> openUpdateDetails("swap")} /> {(() => { const h = updates?.components?.find((c) => c.key === "hermes_agent") return ( openUpdateDetails("hermes")} /> ) })()} {updates?.model_list?.map((m) => ( triggerModelUpgrade(m.repo, m.role)} /> ))}
{/* Dienste — 1:1 die Backend-Liste (inkl. LLM-Gateway + Wächter seit UMBAU v3) */}

Dienste

{svcList.length === 0 && (
Dienste-Liste wird geladen …
)} {svcList.map((s) => ( restartService(s.unit)} onLogs={() => { setSelectedService(s.unit); setActiveTab("logs") }} /> ))}
{/* Backup — Liste kommt aus derselben Quelle wie die Zeitmaschine (Chronik) */}

Backup

{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}
{backups.length} Snapshots · Wiederherstellen: Chronik → Zeitmaschine
{backupMsg && (
{backupMsg}
)}
{/* Gefahrenzone */}

Gefahrenzone

{/* Active Jobs */}

Hintergrund-Aufgaben

{jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv
{jobs.length === 0 ? (
Aktuell keine aktiven Hintergrund-Jobs.
) : ( jobs.map((job) => { const isActive = job.state === "running" || job.state === "queued" return (
{isActive && ( )} {job.label}
ID: {job.id} {job.state}
{isActive && ( )}
{/* Progress */} {job.state === "running" && (
{job.progress ?? 0}% {job.done_bytes != null && job.total_bytes != null && ( {fmtBytes(job.done_bytes)} / {fmtBytes(job.total_bytes)} {job.rate_bps != null && ` (${fmtBytes(job.rate_bps)}/s)`} )} {job.eta_s != null && ETA: {job.eta_s}s}
)}
) }) )}
)} {activeTab === "logs" && ( // Logs View
{/* Service Select & Restart — dieselbe Backend-Liste wie die Wartungs-Ansicht */}
loadServiceLogs(selectedService)} onOpenSettings={() => setActiveTab("settings")} />
)} {activeTab === "settings" && }
{detail && ( setDetail(null)} onApply={applyFromDetail} /> )} {dialogElement} ) }