Refactor: Diagnose-Tab in Zentrale gemerged + Zentrale neu strukturiert

Diagnose war groesstenteils Dublette (Metriken/Temps schon in Zentrale; Logs/Restart/
Updates im Pflege-Drawer). Tab entfernt, zwei einzigartige Teile umverteilt.

- Zentrale neu in 3 Zonen: Live-Telemetrie (System-Status + Token-Durchsatz nebeneinander
  statt gestapelt), Stack-Status (Aktive Modelle / Rollen / Dienste), Betrieb & Wissen
  (Updates / Hermes / Gedaechtnis).
- Neue ServicesCard (Dienste-Health aus Diagnose) auf der Zentrale.
- TokenStatsCard ('Effizienz & Ersparnis') in TokenPerformanceCard gemerged (Input/Output
  + gespart) -> eine Karte weniger, keine Dublette.
- Backup/Snapshot in den System-Wartung-Drawer verschoben.
- nav.ts/App.tsx: 'system'-View entfernt (6 statt 7 Tabs); SystemView.tsx geloescht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 16:57:16 +02:00
parent 35189fde0e
commit ec9488d94e
14 changed files with 630 additions and 792 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Dn30RTcO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4fQuXTp.css">
<script type="module" crossorigin src="/assets/index-CauIIHdl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-wQ2PPr04.css">
</head>
<body>
<div id="root"></div>
+1 -3
View File
@@ -4,7 +4,6 @@ import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { DashboardView } from "@/views/DashboardView"
import { ModelsView } from "@/views/ModelsView"
import { SystemView } from "@/views/SystemView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView"
@@ -176,12 +175,11 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <DashboardView />}
{view === "models" && <ModelsView />}
{view === "system" && <SystemView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />}
{view === "guide" && <GuideView />}
{!["dashboard", "models", "system", "connect", "memory", "agent", "guide"].includes(view) && (
{!["dashboard", "models", "connect", "memory", "agent", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
+38 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle } from "lucide-react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle, Save } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
@@ -35,6 +35,8 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
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)
// Custom Dialog State
const [dialog, setDialog] = useState<{
@@ -222,6 +224,19 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
)
}
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} Dateien)` : "Keine Änderungen zu sichern.")
} catch (e: any) {
setBackupMsg(`Fehler: ${e.message}`)
} finally {
setBackupRunning(false)
}
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
try {
@@ -384,6 +399,28 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</button>
</div>
{/* Backup / Snapshot */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">System-Backup &amp; Snapshot</h3>
<div className="p-4 rounded-xl border border-border/60 bg-background/20 space-y-3">
<p className="text-[10px] text-muted-foreground leading-normal">
Git-Snapshot der aktuellen Konfigurationen und des System-Zustands erzeugen.
</p>
<button
onClick={doBackup}
disabled={backupRunning}
className="w-full h-9 flex items-center justify-center gap-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer disabled:opacity-50 shadow-md shadow-primary/10"
>
<Save className={cn("h-4 w-4", backupRunning && "animate-pulse")} /> Snapshot erstellen
</button>
{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>
</div>
{/* Modell Upgrades */}
{updates?.model_list && updates.model_list.length > 0 && (
<div className="space-y-3">
@@ -0,0 +1,79 @@
import { useState } from "react"
import { ExternalLink, RefreshCw, Server } from "lucide-react"
import { api } from "@/lib/api"
import { useServices } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function ServicesCard() {
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const [restarting, setRestarting] = useState<Record<string, boolean>>({})
async function restart(id: string) {
setRestarting((p) => ({ ...p, [id]: true }))
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: id }),
})
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
} catch (e: any) {
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestarting((p) => ({ ...p, [id]: false }))
}
}
return (
<div className="flex flex-col rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
>
Logs / Pflege
</button>
</div>
{svc ? (
<div className="flex flex-1 flex-col">
<div className="space-y-1.5">
{svc.services.map((x) => (
<div key={x.name} className="group flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-black/40", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
<div className="min-w-0">
<div className="truncate text-xs font-bold text-foreground">{x.name}</div>
<div className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
</div>
</div>
<button
onClick={() => restart(x.name)} disabled={restarting[x.name]}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-primary/5 hover:text-primary group-hover:opacity-100"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.name] && "animate-spin")} />
</button>
</div>
))}
</div>
<div className="mt-auto flex flex-wrap gap-3 border-t border-border/20 pt-2.5 text-[10px] font-semibold text-muted-foreground">
<a href={resolveExternalUrl(svc.links.engine_ui)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Engine
</a>
<a href={resolveExternalUrl(svc.links.gateway)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Gateway
</a>
</div>
</div>
) : (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste</div>
)}
{dialogElement}
</div>
)
}
@@ -28,7 +28,7 @@ export function SystemStatusCard() {
}
return (
<div className="md:col-span-2 flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div>
<div className="mb-3 flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
@@ -55,8 +55,13 @@ export function TokenPerformanceCard() {
</div>
)}
{ts && (
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
{ts.total_tokens.toLocaleString("de-DE")} Tokens gesamt · {ts.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} gespart
<div className="mt-0.5 space-y-0.5 font-mono text-[11px] text-muted-foreground/70">
<div>
{ts.total_tokens.toLocaleString("de-DE")} Tokens gesamt · <span className="text-emerald-400/90">{ts.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} gespart</span>
</div>
<div className="text-muted-foreground/55">
Input {ts.prompt_tokens.toLocaleString("de-DE")} · Output {ts.completion_tokens.toLocaleString("de-DE")}
</div>
</div>
)}
</div>
@@ -1,61 +0,0 @@
import { Coins } from "lucide-react"
import { useTokenStats } from "@/lib/queries"
export function TokenStatsCard() {
const { data: tokenStats } = useTokenStats(3_000)
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Coins className="h-4.5 w-4.5 text-primary animate-pulse" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Effizienz &amp; Ersparnis</h2>
</div>
{tokenStats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2.5">
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Geld gespart</div>
<div className="text-base font-bold text-emerald-400 mt-0.5 tracking-tight font-space">
{tokenStats.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })}
</div>
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
({tokenStats.saved_usd.toLocaleString("en-US", { minimumFractionDigits: 2 })} $)
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Gesamt-Tokens</div>
<div className="text-base font-bold text-primary mt-0.5 tracking-tight font-space">
{tokenStats.total_tokens.toLocaleString("de-DE")}
</div>
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
(Lokale Inferenz)
</div>
</div>
</div>
<div className="space-y-2 border-t border-border/20 pt-3 text-[10px] text-muted-foreground">
<div className="flex justify-between items-center font-mono">
<span>Input (Prompts):</span>
<span className="font-semibold text-foreground">{tokenStats.prompt_tokens.toLocaleString("de-DE")} tkn</span>
</div>
<div className="flex justify-between items-center font-mono">
<span>Output (Antworten):</span>
<span className="font-semibold text-foreground">{tokenStats.completion_tokens.toLocaleString("de-DE")} tkn</span>
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Statistiken</div>
)}
</div>
<div className="mt-4 text-[9px] text-muted-foreground/70 border-t border-border/30 pt-2.5 leading-normal">
Berechnet ggü. Cloud-APIs
{tokenStats?.pricing?.heavy
? `${(tokenStats.pricing.heavy.in ?? 0).toFixed(2).replace(".", ",")} $ / ${(tokenStats.pricing.heavy.out ?? 0).toFixed(2).replace(".", ",")} $ pro 1M tkn).`
: "."}
</div>
</div>
)
}
+1 -3
View File
@@ -1,7 +1,6 @@
import {
LayoutDashboard,
Boxes,
Cpu,
Brain,
Plug,
Bot,
@@ -9,7 +8,7 @@ import {
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "models" | "system" | "memory" | "connect" | "agent" | "guide"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "guide"
export interface NavItem {
id: ViewId
@@ -22,7 +21,6 @@ export interface NavItem {
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Zentrale", hint: "System- & Stack-Status", icon: LayoutDashboard },
{ id: "models", label: "Modell-Manager", hint: "Verwalten, laden & Gateway-Routing", icon: Boxes },
{ id: "system", label: "Diagnose", hint: "Metriken, Dienste, Logs", icon: Cpu },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & AnythingLLM öffnen", icon: Bot },
+32 -20
View File
@@ -4,13 +4,16 @@ import { UpdatesCard } from "@/components/dashboard/UpdatesCard"
import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
import { RolesCard } from "@/components/dashboard/RolesCard"
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
import { TokenStatsCard } from "@/components/dashboard/TokenStatsCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { ServicesCard } from "@/components/dashboard/ServicesCard"
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
}
export function DashboardView() {
return (
<div className="space-y-6">
{/* Title */}
<div className="space-y-7">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Zentrale
@@ -18,25 +21,34 @@ export function DashboardView() {
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
</div>
{/* Live: aktuell geladene/arbeitende Modelle */}
<ActiveModelsCard />
{/* Zone 1 — Live-Telemetrie: beide Charts nebeneinander */}
<section>
<ZoneLabel>Live-Telemetrie</ZoneLabel>
<div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard />
<TokenPerformanceCard />
</div>
</section>
{/* Top Grid: System stats & Updates */}
<div className="grid gap-6 md:grid-cols-3">
<SystemStatusCard />
<UpdatesCard />
</div>
{/* Zone 2 — Stack-Status */}
<section>
<ZoneLabel>Stack-Status</ZoneLabel>
<div className="grid gap-6 md:grid-cols-3">
<ActiveModelsCard />
<RolesCard />
<ServicesCard />
</div>
</section>
{/* Live-Durchsatz (Performance) */}
<TokenPerformanceCard />
{/* Bottom Grid: Agent, Roles, Memory & Token Stats */}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<AgentStatusCard />
<RolesCard />
<MemoryInputCard />
<TokenStatsCard />
</div>
{/* Zone 3 — Betrieb & Wissen */}
<section>
<ZoneLabel>Betrieb &amp; Wissen</ZoneLabel>
<div className="grid gap-6 md:grid-cols-3">
<UpdatesCard />
<AgentStatusCard />
<MemoryInputCard />
</div>
</section>
</div>
)
}
-225
View File
@@ -1,225 +0,0 @@
import { useState } from "react"
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api } from "@/lib/api"
import { useServices } from "@/lib/queries"
import { useSystemHistory, type SysSample } from "@/lib/useSystemHistory"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { gb } from "@/lib/format"
import { LiveAreaChart } from "@/components/dashboard/LiveAreaChart"
function MetricChartCard({ label, percent, detail, icon: Icon, color, seriesKey, data }: {
label: string; percent: number; detail?: string; icon: any
color: string; seriesKey: keyof SysSample; data: SysSample[]
}) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10 hover:border-primary/30 transition-all duration-300">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4.5 w-4.5" style={{ color }} />
<span className="text-xs font-semibold uppercase tracking-wider text-foreground">{label}</span>
</div>
<span className="font-mono text-sm font-bold tabular-nums text-foreground">{Math.round(percent)}%</span>
</div>
{detail && <div className="mb-1 font-mono text-[10px] text-muted-foreground/70">{detail}</div>}
<LiveAreaChart data={data} series={[{ key: seriesKey as string, label, color }]} unit="%" yMode="percent" height={88} />
</div>
)
}
export function SystemView() {
const { sys: s, hist, error: sErr } = useSystemHistory()
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const error = sErr ? String(sErr) : ""
const [backupMsg, setBackupMsg] = useState("")
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
async function doBackup() {
setBackupMsg("Backup snapshotted...")
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} Dateien)` : "Keine Änderungen zu sichern.")
} catch (e: any) {
setBackupMsg(`Fehler: ${e.message}`)
}
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: serviceId })
})
if (r.ok) {
showAlert("Erfolgreich", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
} else {
showAlert("Fehler beim Neustart", `Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
System-Diagnose &amp; Status
</h1>
<p className="text-sm text-muted-foreground flex items-center gap-1">
Echtzeit-Ressourcen der Box, Dienst-Überwachung und Systempflege.
</p>
</div>
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400 font-mono">
System-Status nicht lesbar ({error}).
</div>
)}
{/* Metrics Section */}
{s && (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricChartCard label="CPU" color="#2dd4bf" seriesKey="cpu" data={hist}
percent={s.cpu.percent} detail={s.cpu.cores ? `${s.cpu.cores} Cores` : undefined} icon={Cpu} />
<MetricChartCard label="RAM" color="#38bdf8" seriesKey="ram" data={hist}
percent={s.ram.percent} detail={`${gb(s.ram.used)} / ${gb(s.ram.total)} GB`} icon={Activity} />
{s.gpu && s.gpu.busy_percent != null && (
<MetricChartCard
label="GPU" color="#a78bfa" seriesKey="gpu" data={hist}
percent={s.gpu.busy_percent}
detail={
s.gpu.gtt_used != null && s.gpu.gtt_total
? `${gb(s.gpu.gtt_used)} / ${gb(s.gpu.gtt_total)} GB (GTT/unified)`
: s.gpu.vram_used != null && s.gpu.vram_total
? `${gb(s.gpu.vram_used)} / ${gb(s.gpu.vram_total)} GB VRAM`
: undefined
}
icon={GpuIcon}
/>
)}
{s.disk && (
<MetricChartCard label="Disk" color="#fbbf24" seriesKey="disk" data={hist}
percent={s.disk.percent} detail={`${gb(s.disk.used)} / ${gb(s.disk.total)} GB`} icon={HardDrive} />
)}
</div>
{/* Temperatures display */}
{s.temp && (s.temp.cpu || s.temp.gpu) && (
<div className="flex gap-3 text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-2.5 rounded-xl self-start w-fit">
{s.temp.cpu != null && (
<span className="flex items-center gap-1">
CPU-Temperatur: <span className="text-foreground font-bold">{s.temp.cpu} °C</span>
</span>
)}
{s.temp.cpu != null && s.temp.gpu != null && <span>|</span>}
{s.temp.gpu != null && (
<span className="flex items-center gap-1">
GPU-Temperatur: <span className="text-foreground font-bold">{s.temp.gpu} °C</span>
</span>
)}
</div>
)}
</div>
)}
{/* Services Health matrix */}
{svc && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Homelab-Dienste</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
className="h-7 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
>
System-Logs anzeigen
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{svc.services.map((x) => (
<div
key={x.name}
className="flex items-center justify-between p-3.5 rounded-xl bg-background/20 border border-border/30 hover:border-primary/20 transition-all group"
>
<div className="flex items-center gap-2.5 min-w-0">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
x.ok ? "bg-emerald-500" : "bg-amber-500"
)} />
<div className="truncate">
<div className="text-xs font-bold text-foreground truncate">{x.name}</div>
<div className="text-[9px] text-muted-foreground/60 font-mono mt-0.5 truncate">{x.url}</div>
</div>
</div>
<button
onClick={() => restartService(x.name)}
disabled={restartingServices[x.name]}
className="h-7 w-7 rounded-lg border border-border/40 text-muted-foreground hover:text-primary hover:bg-primary/5 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restartingServices[x.name] && "animate-spin")} />
</button>
</div>
))}
</div>
{/* Service Links */}
<div className="flex flex-wrap gap-4 border-t border-border/20 pt-4 text-[10px] font-semibold text-muted-foreground">
<a
href={resolveExternalUrl(svc.links.engine_ui)}
target="_blank"
rel="noopener"
className="flex items-center gap-1 text-primary hover:text-primary-foreground hover:bg-primary/10 px-2 py-1 rounded transition-colors"
>
<ExternalLink className="h-3 w-3" /> Engine Dashboard (llama-swap)
</a>
<a
href={resolveExternalUrl(svc.links.gateway)}
target="_blank"
rel="noopener"
className="flex items-center gap-1 text-primary hover:text-primary-foreground hover:bg-primary/10 px-2 py-1 rounded transition-colors"
>
<ExternalLink className="h-3 w-3" /> OpenAI Gateway
</a>
</div>
</div>
)}
{/* Backup snapshot panel */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div className="space-y-1">
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">System-Backup &amp; Snapshot</h3>
<p className="text-[10px] text-muted-foreground">Erzeuge einen Git-Snapshot der aktuellen Konfigurationen und des System-Zustands.</p>
</div>
<div className="flex items-center gap-3 self-start sm:self-auto shrink-0">
<button
onClick={doBackup}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10"
>
<Save className="h-4 w-4" /> Snapshot erstellen
</button>
</div>
</div>
{backupMsg && (
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-3 rounded-xl border border-primary/20">
{backupMsg}
</div>
)}
{dialogElement}
</div>
)
}