f2d357c5d8
Neuer POST /api/maintenance/update-all kettet die AUSSTEHENDEN Updates sequenziell (Engine -> Router -> Hermes -> OS) in einem maintenance-Job. Bewusst reine Wiederverwendung: jeder Teil ist exakt der Befehl des Einzel-Updates inkl. dessen Backup/Postcheck/Selbst-Rollback; &&-Kette stoppt beim ersten Fehler, Banner-Zeilen im Log zeigen den Schritt. OS zuletzt (breitester Eingriff, braucht als einziges das Box-Passwort; fehlt es, laufen die sudo-freien Teile trotzdem und OS wird uebersprungen). Hermes-Befehlskette in _hermes_update_cmd() extrahiert (DRY). UI (SystemDrawer/Updates): Button 'Alle aktualisieren (N)' neben der Update-Suche, nur sichtbar wenn etwas aussteht, gesperrt waehrend ein Wartungs-Job laeuft, mit Bestaetigungs-Dialog der die Kette benennt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
679 lines
30 KiB
TypeScript
679 lines
30 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 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()
|
|
}
|
|
|
|
// „Alle aktualisieren": kettet alle AUSSTEHENDEN Updates in EINEM Wartungs-Job
|
|
// (Engine → Router → Hermes → OS; das Backend nutzt exakt die Einzel-Update-Befehle
|
|
// inkl. deren Backup/Postcheck/Rollback — 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 }
|
|
loadJobs()
|
|
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" })
|
|
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")
|
|
// 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)
|
|
|
|
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>
|
|
<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 */}
|
|
<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}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|