feat: complete UI/UX Rework into Sleek Glassmorphic AI OS (June 2026)

This commit is contained in:
Hitonabi
2026-06-25 22:18:51 +02:00
parent e1da5c797d
commit 6a8e55cc43
18 changed files with 2362 additions and 787 deletions
+406
View File
@@ -0,0 +1,406 @@
import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils"
interface SystemDrawerProps {
open: boolean
onClose: () => void
}
const SERVICES = [
{ id: "llama-swap", label: "Llama Swap", type: "system" },
{ id: "mission-control-2", label: "Mission Control 2", type: "user" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user" },
{ id: "hermes-dashboard", label: "Hermes Dashboard", type: "user" },
{ id: "hermes-webui", label: "Hermes WebUI", type: "user" }
]
function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
export function SystemDrawer({ open, onClose }: 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 [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<"maintenance" | "logs">("maintenance")
const logContainerRef = useRef<HTMLPreElement | null>(null)
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 loadServiceLogs(service: string) {
setLoadingLogs(true)
api<{ ok: boolean; text: string; err?: 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"}`)
}
})
.catch((e) => setLogs(`Fehler: ${e.message}`))
.finally(() => {
setLoadingLogs(false)
// Scroll to bottom
setTimeout(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, 50)
})
}
// Poll jobs & updates when open
useEffect(() => {
if (!open) return
loadUpdates()
loadJobs()
const timer = setInterval(() => {
loadJobs()
loadUpdates()
}, 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])
async function triggerOsUpdate() {
try {
await api<{ job_id: string }>("/api/maintenance/os-update", { method: "POST" })
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler beim Starten des OS-Updates: ${e.message}`)
}
}
async function triggerEngineUpdate() {
try {
await api<{ job_id: string }>("/api/maintenance/engine-update", { method: "POST" })
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function triggerReboot() {
if (!confirm("Bist du sicher, dass du das gesamte Host-System neu starten willst?")) return
try {
await api("/api/maintenance/reboot", { method: "POST" })
alert("Reboot ausgelöst. System startet neu...")
onClose()
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
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) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
if (activeTab === "logs" && selectedService === serviceId) {
loadServiceLogs(serviceId)
}
} else {
alert(`Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
alert(`Fehler: ${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) {
alert(`Fehler beim Abbrechen: ${e.message}`)
}
}
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-[500px] 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>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" ? (
<>
{/* Quick Actions */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3>
<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>
</div>
<button
onClick={triggerReboot}
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"
>
<Power className="h-4.5 w-4.5" />
<div>
<div>Host-System neu starten (Reboot)</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet das gesamte Betriebssystem des Homelabs 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>
</>
) : (
// 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>
{/* Log Console Container */}
<div className="flex-1 flex flex-col min-h-[300px] border border-border/60 bg-black/50 rounded-xl overflow-hidden shadow-inner">
{/* Console Header */}
<div className="flex h-9 items-center justify-between px-4 border-b border-border/40 bg-black/30 shrink-0">
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">
<Terminal className="h-3 w-3 text-primary" />
<span>stdout/stderr - {selectedService}</span>
</div>
<button
onClick={() => loadServiceLogs(selectedService)}
disabled={loadingLogs}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className={cn("h-3 w-3", loadingLogs && "animate-spin")} />
</button>
</div>
{/* Console Log Area */}
<pre
ref={logContainerRef}
className="flex-1 p-4 overflow-y-auto text-[10px] font-mono text-cyan-300/90 whitespace-pre-wrap select-text leading-relaxed scrollbar-thin"
>
{loadingLogs && !logs ? (
<span className="text-muted-foreground">Lade Logs...</span>
) : (
logs || <span className="text-muted-foreground">Keine Logeinträge vorhanden.</span>
)}
</pre>
</div>
</div>
)}
</div>
</div>
</>
)
}