Files
mission-control-v2/frontend/src/components/SystemDrawer.tsx
T
2026-07-04 21:47:19 +02:00

640 lines
27 KiB
TypeScript

import { useEffect, useState } from "react"
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle } from "lucide-react"
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows"
import { LogConsole } from "./system/LogConsole"
import { SettingsTab } from "./system/SettingsTab"
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
interface SystemDrawerProps {
open: boolean
onClose: () => void
defaultTab?: "maintenance" | "logs" | "settings"
}
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [jobs, setJobs] = useState<Job[]>([])
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 [services, setServices] = useState<ServicesResp | null>(null)
const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([])
const [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
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])
function loadUpdates() {
api<UpdatesResp>("/api/maintenance/updates")
.then(setUpdates)
.catch((e) => console.error("Error loading updates", e))
}
function loadJobs() {
api<{ jobs: Job[] }>("/api/jobs")
.then((data) => setJobs(data.jobs || []))
.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) {
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))
}
// Poll jobs & updates when open
useEffect(() => {
if (!open) return
loadUpdates()
loadJobs()
loadServices()
loadBackups()
const timer = setInterval(() => {
loadJobs()
loadUpdates()
loadServices()
}, 3000)
return () => clearInterval(timer)
}, [open])
// Load logs when tab is active or service changes
useEffect(() => {
if (!open || activeTab !== "logs") return
loadServiceLogs(selectedService)
}, [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.`)
loadJobs()
return true
}
return false
}
// Passwort-Fehler NICHT mehr verschlucken: OS-Update braucht das Box-Passwort. Früher
// lief die Aktion bei fehlendem/falschem Passwort ins Leere („passiert nichts").
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 triggerOsUpdate() {
try {
const res = await api<any>("/api/maintenance/os-update", { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler beim Starten des OS-Updates: ${e.message}`)
}
}
async function triggerEngineUpdate() {
try {
const res = await api<any>("/api/maintenance/engine-update", { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler beim Engine-Update: ${e.message}`)
}
}
async function triggerSwapUpdate() {
try {
const res = await api<any>("/api/maintenance/swap-update", { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler beim Router-Update: ${e.message}`)
}
}
async function doHermesUpdate() {
setHermesUpdating(true)
try {
const res = await api<any>("/api/maintenance/hermes-update", { method: "POST" })
if (handledBusy(res)) return
loadJobs()
} 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") triggerOsUpdate()
else if (kind === "engine") triggerEngineUpdate()
else if (kind === "swap") triggerSwapUpdate()
else if (kind === "hermes") doHermesUpdate()
}
async function triggerCheckUpdates() {
setCheckingUpdates(true)
try {
await api("/api/maintenance/check-updates", { method: "POST" })
loadJobs()
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.`)
loadJobs()
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.")
loadBackups()
} 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)
}
})
} 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" })
loadJobs()
} 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")
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">
<button
onClick={() => setActiveTab("maintenance")}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === "maintenance"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
System-Wartung
</button>
<button
onClick={() => setActiveTab("logs")}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === "logs"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
System-Logs
</button>
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === "settings"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
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>
<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>
{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 */}
<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">
{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" />
<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>
{formatBytes(job.done_bytes)} / {formatBytes(job.total_bytes)}
{job.rate_bps != null && ` (${formatBytes(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 */}
<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"
>
{SERVICES.map((s) => (
<option key={s.id} value={s.id}>
{s.label} ({s.type === "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} />
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</>
)
}