feat: complete UI/UX Rework into Sleek Glassmorphic AI OS (June 2026)
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user