umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben

Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:39:07 +02:00
co-authored by Claude Opus 5.5
parent 3d2c9549fb
commit 907289d7dc
223 changed files with 8572 additions and 15393 deletions
@@ -1,38 +0,0 @@
import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Canvas-Crashes.)
interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
<div className="max-w-lg space-y-4 text-center">
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
<div className="text-sm text-neutral-400 break-all">
{this.state.error.message}
</div>
<button
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
onClick={() => window.location.reload()}
>
Neu laden
</button>
</div>
</div>
)
}
}
-27
View File
@@ -1,27 +0,0 @@
import type { Capabilities } from "@/lib/api"
function Chip({ children, tone = "muted" }: { children: React.ReactNode; tone?: "muted" | "primary" | "warn" }) {
const tones = {
muted: "bg-muted text-muted-foreground",
primary: "bg-primary/15 text-primary",
warn: "bg-amber-500/15 text-amber-500",
}
return (
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${tones[tone]}`}>{children}</span>
)
}
export function CapsChips({ caps }: { caps?: Capabilities }) {
if (!caps) return null
return (
<span className="inline-flex flex-wrap gap-1">
{caps.coder && <Chip>💻 Code</Chip>}
{caps.vision && <Chip>👁 Bild</Chip>}
{caps.reasoning && <Chip>🧠 Reason</Chip>}
{caps.moe && <Chip tone="primary">🧩 MoE{caps.active_b ? `·${caps.active_b}b` : ""}</Chip>}
{caps.tools === "yes" && <Chip tone="primary">🛠 Tools</Chip>}
{caps.tools === "likely" && <Chip tone="warn">🛠 Tools?</Chip>}
{caps.embedding && <Chip>🔢 Embed</Chip>}
</span>
)
}
-161
View File
@@ -1,161 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Command } from "cmdk"
import { useNavigate } from "@tanstack/react-router"
import { AlertTriangle, CornerDownLeft } from "lucide-react"
import { useMC, usePaletteOffen } from "@/app/store"
import { api } from "@/lib/api"
import { cn } from "@/lib/utils"
import {
GRUPPEN_TITEL, befehle, suchtext,
type Befehl, type BefehlsGruppe,
} from "@/app/palette/register"
// Befehlspalette 2.0 (v3-Umbau P6, Spezifikation §4.6).
//
// Vorher listete sie die elf Navigations-Einträge — ein Sprungbrett. Jetzt tippt man,
// WAS man will, nicht wo es liegt: springen, tun, finden, oder den Text direkt als Idee
// an Lucy geben. Das Register steht in app/palette/register.ts.
//
// Die Rückfrage ist keine Höflichkeit, sondern Absicht: Die Palette ist schnell genug,
// dass „Strg+K, mot, Enter" den Motor neu startet, bevor man es zu Ende gedacht hat.
// Befehle mit `rueckfrage` halten deshalb an und sagen erst, was passieren wird.
const GRUPPEN_REIHE: BefehlsGruppe[] = ["gehe", "tun", "finden", "lucy"]
export function CommandPalette() {
const offen = usePaletteOffen()
const palette = useMC((s) => s.palette)
const melden = useMC((s) => s.melden)
const navigate = useNavigate()
const [text, setText] = useState("")
const [nachfrage, setNachfrage] = useState<Befehl | null>(null)
const [laeuft, setLaeuft] = useState(false)
const schliessen = useCallback(() => {
palette(false)
setText("")
setNachfrage(null)
}, [palette])
const ausfuehren = useCallback(async (b: Befehl) => {
setLaeuft(true)
try {
await b.ausfuehren({
text,
navigate: (pfad) => navigate({ to: pfad }),
schublade: (tab) => navigate({ to: ".", search: (a) => ({ ...a, system: tab }) }),
melden,
api,
})
schliessen()
} catch (e) {
// Der echte Grund aus ApiError.detail (P1/B-14) — nicht „Fehler".
melden("fehler", e instanceof Error ? e.message : String(e))
setNachfrage(null)
} finally {
setLaeuft(false)
}
}, [text, navigate, melden, schliessen])
const liste = useMemo(() => befehle(text), [text])
return (
<Command.Dialog
open={offen}
onOpenChange={(o) => (o ? palette(true) : schliessen())}
label="Befehlspalette"
className="fixed inset-0 z-50 flex items-start justify-center overscroll-contain bg-black/50 p-4 pt-[12vh]"
onClick={schliessen}
shouldFilter={!nachfrage}
>
<div
className="w-full max-w-xl overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{nachfrage ? (
// ── Zweistufige Rückfrage ────────────────────────────────────────
<div className="space-y-4 p-5">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
<h2 className="font-space text-sm font-bold tracking-tight">{nachfrage.label}</h2>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">{nachfrage.rueckfrage}</p>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setNachfrage(null)}
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
>
Abbrechen
</button>
<button
autoFocus
disabled={laeuft}
onClick={() => ausfuehren(nachfrage)}
className="h-9 cursor-pointer rounded-lg bg-amber-500/90 px-4 text-xs font-bold text-black transition-colors hover:bg-amber-500 disabled:opacity-50"
>
{laeuft ? "Läuft …" : "Ja, ausführen"}
</button>
</div>
</div>
) : (
<>
<Command.Input
autoFocus
value={text}
onValueChange={setText}
placeholder="Springen, tun, finden — oder schreib eine Idee …"
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Command.List className="max-h-[26rem] overflow-y-auto p-2 scrollbar-thin">
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
Nichts gefunden.
</Command.Empty>
{GRUPPEN_REIHE.map((gruppe) => {
const eintraege = liste.filter((b) => b.gruppe === gruppe)
if (!eintraege.length) return null
return (
<Command.Group
key={gruppe}
heading={GRUPPEN_TITEL[gruppe]}
className="px-1 py-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50"
>
{eintraege.map((b) => (
<Command.Item
key={b.id}
value={suchtext(b)}
onSelect={() => (b.rueckfrage ? setNachfrage(b) : ausfuehren(b))}
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<b.icon
className={cn("h-4 w-4 shrink-0", b.rueckfrage ? "text-amber-400" : "text-primary")}
aria-hidden="true"
/>
<span className="truncate">{b.label}</span>
{b.rueckfrage && (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-300">
fragt nach
</span>
)}
{b.hinweis && (
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{b.hinweis}</span>
)}
</Command.Item>
))}
</Command.Group>
)
})}
</Command.List>
<div className="flex items-center gap-3 border-t border-border/50 px-4 py-2 font-mono text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">↑↓ wählen</span>
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" aria-hidden="true" /> ausführen</span>
<span className="ml-auto">esc schließt</span>
</div>
</>
)}
</div>
</Command.Dialog>
)
}
-84
View File
@@ -1,84 +0,0 @@
import { useRef } from "react"
import { X } from "lucide-react"
export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={title}>
<div className="w-full max-w-sm rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">{title}</h3>
<button
onClick={onCancel || (() => onConfirm())}
aria-label="Schließen"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* whitespace-pre-line + Scroll: mehrzeilige Botschaften (z. B. Dubletten-Vorschau,
Annehmen-Warnungen) sauber rendern statt zu einer Wurst zusammenzufallen. */}
<p className="max-h-72 overflow-y-auto whitespace-pre-line text-xs text-muted-foreground leading-relaxed scrollbar-thin">{message}</p>
{type === "prompt" && (
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
defaultValue={defaultValue}
aria-label={title}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
onConfirm(inputRef.current?.value)
}
}}
/>
{autoValue !== undefined && (
<button
type="button"
onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }}
title="Setup-bewussten Optimalwert eintragen"
className="h-9 px-3 shrink-0 text-xs font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors cursor-pointer"
>
{autoLabel || "Auto"}
</button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
{(type === "confirm" || type === "prompt") && (
<button
onClick={onCancel}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Abbrechen
</button>
)}
<button
onClick={() => {
const val = type === "prompt" ? inputRef.current?.value : undefined
onConfirm(val)
}}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
>
{type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
</button>
</div>
</div>
</div>
)
}
-38
View File
@@ -1,38 +0,0 @@
import { Sparkles, SlidersHorizontal } from "lucide-react"
import { useExpertenmodus, useMC } from "@/app/store"
import { cn } from "@/lib/utils"
// Zwei-Zustand-Pille im Header: „Einfach" (aufgeräumt) ↔ „Experte" (alle Details).
export function ExpertToggle() {
const expert = useExpertenmodus()
const setExpertMode = useMC((s) => s.expertenmodus)
return (
<div
className="flex items-center rounded-md border border-border/40 bg-background/40 p-0.5"
role="group"
aria-label="Ansichtsmodus"
title="Einfach zeigt nur das Wichtigste. Experte zeigt alle technischen Details."
>
<button
onClick={() => setExpertMode(false)}
aria-pressed={!expert}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
!expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<Sparkles className="h-3.5 w-3.5" /> Einfach
</button>
<button
onClick={() => setExpertMode(true)}
aria-pressed={expert}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" /> Experte
</button>
</div>
)
}
-61
View File
@@ -1,61 +0,0 @@
import { AlertTriangle, RotateCcw } from "lucide-react"
// Fehlerfläche EINER Ansicht (v3-Umbau P2, Befund B-13).
//
// Vorher hing genau eine Fehlergrenze in main.tsx um die gesamte App: Ein Renderfehler
// im Modell-Manager nahm Cockpit, Konsole und Chronik gleich mit — die Seite war weiß.
// Jetzt fängt jede Route für sich; Schiene, Kopfzeile und Statusanzeige bleiben stehen,
// und der Nutzer kann einfach woanders hin.
//
// Die Meldung sagt, was passiert ist und was man tun kann. Der technische Text steht
// aufklappbar darunter — nicht versteckt (man braucht ihn für einen Fehlerbericht),
// aber auch nicht als Erstes im Gesicht.
export function RouteFehler({ titel, text, fehler, onErneut }: {
titel: string
text: string
fehler?: unknown
onErneut?: () => void
}) {
const detail = fehler instanceof Error ? (fehler.stack || fehler.message) : fehler ? String(fehler) : null
return (
<div className="flex min-h-[50vh] items-center justify-center p-6">
<div className="mc-card max-w-lg space-y-4 p-6">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
<h1 className="font-space text-lg font-bold tracking-tight">{titel}</h1>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
<div className="flex flex-wrap gap-2 pt-1">
{onErneut && (
<button
onClick={onErneut}
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Erneut versuchen
</button>
)}
<a
href="/cockpit"
className="flex h-9 items-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
>
Zum Cockpit
</a>
</div>
{detail && (
<details className="pt-1">
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
Technische Einzelheiten
</summary>
<pre className="scrollbar-thin mt-2 max-h-52 overflow-auto rounded-lg border border-border/40 bg-background/40 p-3 font-mono text-[10px] leading-relaxed text-muted-foreground">
{detail}
</pre>
</details>
)}
</div>
</div>
)
}
-576
View File
@@ -1,576 +0,0 @@
import { useEffect, useState } from "react"
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle, DownloadCloud } from "lucide-react"
import { api, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
import { useDialog } from "@/lib/useDialog"
import { useJobs, useServices, useUpdates, useZeitmaschine, useQueryClient, qk, invalidate } from "@/lib/queries"
import { fmtBytes } from "@/lib/format"
import { ServiceRow, UpdateRow } from "./system/rows"
import { LogConsole } from "./system/LogConsole"
import { SettingsTab } from "./system/SettingsTab"
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
// System-Schublade (Pflege/Logs/Einstellungen). Datenweg: AUSSCHLIESSLICH die zentralen
// React-Query-Hooks — früher pollte hier ein eigener setInterval alle 3 s dieselben
// Endpunkte (inkl. des teuren Updates-Checks) am Query-Layer vorbei, wodurch Cockpit
// und Schublade verschiedene Stände zeigen konnten (Review 15.07.). Die Dienste-Liste
// kommt 1:1 aus /api/system/services (unit + scope), keine UI-Kopie mehr.
interface SystemDrawerProps {
open: boolean
onClose: () => void
defaultTab?: "maintenance" | "logs" | "settings"
}
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
// Geteilte Caches; Jobs/Zeitmaschine beobachten nur bei offener Schublade.
const { data: updates } = useUpdates()
const { data: services } = useServices()
const jobs = useJobs(3_000, open).data ?? []
const { data: zeit } = useZeitmaschine(undefined, open)
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 [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
const svcList = services?.services ?? []
const refreshJobs = () => invalidate(qc, qk.jobs)
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])
// Beim Öffnen einmal frisch ziehen — danach übernehmen die normalen Poll-Takte.
useEffect(() => {
if (open) invalidate(qc, qk.updates, qk.services, qk.jobs, qk.zeitmaschine)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
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))
}
// Load logs when tab is active or service changes
useEffect(() => {
if (!open || activeTab !== "logs") return
loadServiceLogs(selectedService)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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.`)
refreshJobs()
return true
}
return false
}
// Passwort-Fehler NICHT verschlucken: OS-Update braucht das Box-Passwort.
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 triggerUpdate(kind: "os" | "engine" | "swap") {
const path = { os: "os-update", engine: "engine-update", swap: "swap-update" }[kind]
try {
const res = await api<any>(`/api/maintenance/${path}`, { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Update konnte nicht starten: ${e.message}`)
}
}
async function doHermesUpdate() {
setHermesUpdating(true)
try {
const res = await api<any>("/api/maintenance/hermes-update", { method: "POST" })
if (handledBusy(res)) return
refreshJobs()
} 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" || kind === "engine" || kind === "swap") triggerUpdate(kind)
else if (kind === "hermes") doHermesUpdate()
}
// „Alle aktualisieren": kettet alle AUSSTEHENDEN Updates in EINEM Wartungs-Job
// (Engine → Router → Hermes → OS; 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 }
refreshJobs()
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" })
invalidate(qc, qk.jobs, qk.updates)
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.`)
refreshJobs()
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.")
invalidate(qc, qk.zeitmaschine)
} 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)
}
})
invalidate(qc, qk.services, qk.health, qk.models)
} 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" })
refreshJobs()
} 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)
const backups = zeit?.backups ?? []
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}
aria-label="System-Schublade schließen"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border/40 px-6">
{(["maintenance", "logs", "settings"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === tab
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "maintenance" ? "System-Wartung" : tab === "logs" ? "System-Logs" : "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 — 1:1 die Backend-Liste (inkl. LLM-Gateway + Wächter seit UMBAU v3) */}
<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">
{svcList.length === 0 && (
<div className="rounded-lg border border-dashed border-border/50 p-4 text-center text-xs text-muted-foreground">
Dienste-Liste wird geladen …
</div>
)}
{svcList.map((s) => (
<ServiceRow key={s.unit} label={s.name} system={s.scope === "system"} ok={s.ok}
busy={restartingServices[s.unit]}
onRestart={() => restartService(s.unit)}
onLogs={() => { setSelectedService(s.unit); setActiveTab("logs") }} />
))}
</div>
</div>
{/* Backup — Liste kommt aus derselben Quelle wie die Zeitmaschine (Chronik) */}
<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 · Wiederherstellen: Chronik → Zeitmaschine</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>
{fmtBytes(job.done_bytes)} / {fmtBytes(job.total_bytes)}
{job.rate_bps != null && ` (${fmtBytes(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 — dieselbe Backend-Liste wie die Wartungs-Ansicht */}
<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"
>
{svcList.map((s) => (
<option key={s.unit} value={s.unit}>
{s.name} ({s.scope === "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} />
)}
{dialogElement}
</>
)
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
offen: boolean
titel: string
text: string
knopf: string
gefahr?: boolean
onJa: () => void
onNein: () => void
children?: ReactNode
}) {
return (
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
<DialogContent className="border-linie bg-panel sm:max-w-lg">
<DialogHeader>
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader>
{children}
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onNein}>
Abbrechen
</Button>
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
{knopf}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,97 @@
import { lazy, Suspense, useState } from "react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { useHinweisAktion } from "@/lib/abfragen"
import { post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen"
import { seit, vor } from "@/lib/zeit"
// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel).
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
function Punkt({ h }: { h: Hinweis }) {
const aktion = useHinweisAktion()
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
const rot = h.stufe === "rot"
async function ausloesen(aid: string, label: string) {
if (aid === "protokoll") {
try {
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
} catch (f) {
melden("fehler", (f as Error).message)
}
return
}
aktion.mutate({ hinweis: h.id, aktion: aid, label })
}
return (
<article className="flex flex-col gap-2.5 border-t border-linie pt-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3>
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" />
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
{rot ? "Jetzt" : "Prüfen"}
</span>
</div>
<p className="ziffern text-[13px] text-text-3">
{seit(h.seit)} · zuletzt {vor(h.zuletzt)}
</p>
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
{h.aktionen.length > 0 && (
<div className="flex flex-wrap gap-2.5 pt-1">
{h.aktionen.map((a, i) => (
<Button
key={a.id}
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
size="sm"
disabled={aktion.isPending}
onClick={() => ausloesen(a.id, a.label)}
>
{a.label}
</Button>
))}
</div>
)}
{protokoll && (
<Suspense fallback={null}>
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
</Suspense>
)}
</article>
)
}
export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) {
const heute = new Date().toDateString()
const erledigtHeute = verlauf.filter(
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute,
)
if (hinweise.length === 0) {
return (
<div className="flex flex-col gap-2 border-t border-linie pt-4">
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
{erledigtHeute.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-text-3">
{erledigtHeute.slice(0, 4).map((v) => (
<li key={`${v.id}-${v.ts}`}>
<span className="ziffern">{vor(v.ts)}</span> · {v.text}
</li>
))}
</ul>
)}
</div>
)
}
return (
<div className="flex flex-col gap-4">
{hinweise.map((h) => (
<Punkt key={h.id} h={h} />
))}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react"
import { cn } from "cn"
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
const ART: Record<ChipArt, string> = {
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
rot: "border-rot-rand bg-rot-grund text-rot-text",
grau: "border-linie bg-erhaben text-text-2",
}
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
return (
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
{children}
</span>
)
}
/** Einheitlicher Platzhalter, solange Daten laden oder wenn die Box nicht antwortet. */
export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string }) {
return (
<div
role={fehler ? "alert" : "status"}
className={cn(
"rounded-2xl border px-5 py-8 text-center text-[15px]",
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
)}
>
{text}
</div>
)
}
@@ -0,0 +1,31 @@
import { cn } from "cn"
import type { FlugplanEintrag } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen" },
fehler: { text: "Fehler", farbe: "text-rot" },
geplant: { text: "Geplant", farbe: "text-text-3" },
}
function Zeile({ e }: { e: FlugplanEintrag }) {
const s = STATUS[e.status] ?? STATUS.geplant
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
return (
<li className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5">
<span className="ziffern text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
<span className="min-w-0 text-base">
{e.titel}
{e.text && <span className="text-text-3">, {e.text}</span>}
</span>
<span className={cn("schild text-[13px]", farbe)}>{s.text}</span>
</li>
)
}
export function Flugplan({ gelaufen, geplant }: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }) {
const alle = [...gelaufen, ...geplant.slice(0, 5)]
if (alle.length === 0) return <p className="text-[15px] text-text-2">Heute ist noch nichts gelaufen, und nichts ist geplant.</p>
return <ol className="m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
}
@@ -0,0 +1,50 @@
// Rundinstrument: 240°-Bogen wie ein Zeigerinstrument, Wert als Leuchtbogen, Zahl in der Mitte.
import { bogenPfad } from "@/lib/anzeige"
export interface Zone {
von: number
bis: number
farbe: string
}
export function Instrument({
anteil,
farbe = "var(--cyan)",
zonen = [],
mitte,
unter,
beschriftung,
ariaText,
}: {
anteil: number | null
farbe?: string
zonen?: Zone[]
mitte: string
unter?: string
beschriftung: string
ariaText: string
}) {
const zeigen = anteil != null && anteil > 0.004
return (
<figure className="m-0 flex min-w-0 flex-col items-center gap-1">
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" />
{zonen.map((z) => (
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
))}
{zeigen && (
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" />
)}
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}>
{mitte}
</text>
{unter && (
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
{unter.toUpperCase()}
</text>
)}
</svg>
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption>
</figure>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
fehler: {
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
name: "text-rot",
wert: "text-rot-text",
},
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung",
aus: "aus",
warn: "braucht Aufmerksamkeit",
info: "Info",
fehler: "Störung",
}
export function Lampe({ lampe }: { lampe: LampenDaten }) {
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
return (
<li
className={cn(
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
stil.feld,
)}
>
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
{lampe.label}
</span>
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
</li>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from "react"
import { cn } from "cn"
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
export function Panel({
titel,
rechts,
children,
className,
id,
}: {
titel?: string
rechts?: ReactNode
children: ReactNode
className?: string
id?: string
}) {
return (
<section
id={id}
aria-label={titel}
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
>
{(titel || rechts) && (
<div className="flex flex-wrap items-center justify-between gap-3">
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
{rechts}
</div>
)}
{children}
</section>
)
}
@@ -0,0 +1,25 @@
import {
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
/** Seitenschublade mit Protokolltext (Journal oder Hermes-Fehlerzeilen), unten zuerst gelesen. */
export function Protokollfenster({ offen, titel, text, onSchliessen }: {
offen: boolean
titel: string
text: string
onSchliessen: () => void
}) {
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="w-full border-linie bg-panel sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
<SheetDescription className="text-text-2">{titel}</SheetDescription>
</SheetHeader>
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
{text}
</pre>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,47 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
import { hauptleuchte } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
type Zustand = Start["zustand"]
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
ok: "border-gruen-rand bg-gruen-grund text-gruen",
info: "border-cyan-rand bg-cyan-grund text-cyan",
stumm: "border-aus-rand bg-aus-grund text-aus-text",
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
}
export function Warnpanel({ zustand, lampen, onAnsehen }: {
zustand: Zustand
lampen: LampenDaten[]
onAnsehen: () => void
}) {
const h = hauptleuchte(zustand)
const aktiv = h.art === "gelb" || h.art === "rot"
return (
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${h.oben} ${h.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[h.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
<span className="font-sans text-sm opacity-80">{h.unten}</span>
</button>
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => (
<Lampe key={l.id} lampe={l} />
))}
</ul>
</section>
)
}
@@ -0,0 +1,30 @@
import { laufzeit } from "@/lib/zeit"
function Ziffer({ z }: { z: string }) {
return (
<span className="ziffern inline-flex h-14 w-[38px] items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] text-[32px] font-medium">
{z}
</span>
)
}
/** Laufzeit als mechanisches Zählwerk: TT T SS H. */
export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined }) {
const lz = laufzeit(sekunden)
const tage = String(Math.min(lz?.[0] ?? 0, 99)).padStart(2, "0")
const std = String(lz?.[1] ?? 0).padStart(2, "0")
const text = lz ? `Laufzeit ohne Neustart: ${lz[0]} Tage, ${lz[1]} Stunden` : "Laufzeit unbekannt"
return (
<figure className="m-0 flex min-w-0 flex-col items-center justify-end gap-1">
<div aria-label={text} role="img" className="flex h-[150px] max-w-full items-center gap-1.5 pb-5">
<Ziffer z={lz ? tage[0] : "–"} />
<Ziffer z={lz ? tage[1] : "–"} />
<span className="schild mr-2 ml-0.5 text-lg font-bold text-text-3">T</span>
<Ziffer z={lz ? std[0] : "–"} />
<Ziffer z={lz ? std[1] : "–"} />
<span className="schild ml-0.5 text-lg font-bold text-text-3">H</span>
</div>
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
</figure>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { bogenPfad, hauptleuchte } from "@/lib/anzeige"
import { Warnpanel } from "./Warnpanel"
import type { Start } from "@/lib/typen"
const ruhig: Start["zustand"] = { stufe: "ok", anzahl: 0, waechter_wach: true, stand: 1, update_laeuft: false }
describe("bogenPfad", () => {
it("beginnt links unten und endet bei vollem Ausschlag rechts unten", () => {
expect(bogenPfad(0, 1, 75)).toBe("M 35.05 132.5 A 75 75 0 1 1 164.95 132.5")
})
it("zeichnet 45 % Speicher als kleinen Bogen bis kurz vor die Spitze", () => {
// 45 % von 240° = 108° → Endwinkel 102°, also knapp links der Senkrechten.
expect(bogenPfad(0, 0.45, 75)).toBe("M 35.05 132.5 A 75 75 0 0 1 84.41 21.64")
})
it("klemmt Werte außerhalb von 0…1", () => {
expect(bogenPfad(-1, 2, 75)).toBe(bogenPfad(0, 1, 75))
})
})
describe("hauptleuchte", () => {
it("bleibt ruhig, wenn nichts offen ist", () => {
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" })
})
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" })
})
it("meldet einen schweigenden Wächter vor allem anderen", () => {
expect(hauptleuchte({ ...ruhig, waechter_wach: false, anzahl: 3, stufe: "rot" }).art).toBe("stumm")
})
})
describe("Warnpanel", () => {
it("zeigt jede Lampe mit Name und Wert", () => {
render(
<Warnpanel
zustand={{ ...ruhig, stufe: "gelb", anzahl: 2 }}
lampen={[
{ id: "motor", label: "Motor", zustand: "ok", wert: "b11057" },
{ id: "jobs", label: "Jobs", zustand: "warn", wert: "2 Hinweise" },
]}
onAnsehen={() => {}}
/>,
)
expect(screen.getByText("Motor")).toBeInTheDocument()
expect(screen.getByText("b11057")).toBeInTheDocument()
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled()
})
})
@@ -1,134 +0,0 @@
import { Gauge } from "lucide-react"
import { useVoiceTrace } from "@/lib/queries"
import type { VoiceTurn } from "@/lib/api"
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Der Gedächtnis-Abruf läuft
// Hermes-intern und ist für MC2 nicht messbar — er steckt in „Hirn".
const SEGMENTS = [
{ key: "stt_ms", label: "STT", color: "#f59e0b" },
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" },
{ key: "hirn_ms", label: "Hirn", color: "#2dd4bf" },
{ key: "gen_ms", label: "Antwort", color: "#60a5fa" },
] as const
const fmt = (ms: number | null | undefined): string =>
ms == null ? "–" : `${(ms / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`
function ago(ts: number): string {
const s = Math.max(0, Date.now() / 1000 - ts)
if (s < 60) return `vor ${Math.round(s)} s`
if (s < 3600) return `vor ${Math.round(s / 60)} min`
return `vor ${Math.round(s / 3600)} h`
}
/** Summe der sichtbaren Balken-Stufen (STT gehört zur gefühlten Latenz dazu; total_ms des Backends
* misst nur den Chat-Request ohne STT). So stimmen Balkenbreite und angezeigte Zahl immer überein. */
function rowTotal(t: VoiceTurn): number {
return SEGMENTS.reduce((sum, s) => sum + ((t[s.key] as number | null) ?? 0), 0)
}
/** Dominante Stufe eines Turns = der „Täter". */
function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | null {
let best: { label: string; color: string; ms: number } | null = null
for (const s of SEGMENTS) {
const ms = t[s.key] as number | null
if (ms != null && (best == null || ms > best.ms)) best = { label: s.label, color: s.color, ms }
}
return best
}
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
return (
<div className="flex items-center gap-2.5">
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
<div className="relative h-4 flex-1 overflow-hidden rounded-sm bg-muted/25" title={title}>
<div className="absolute inset-0 flex">
{SEGMENTS.map((s) => {
const ms = t[s.key] as number | null
if (!ms || ms <= 0) return null
return (
<div
key={s.key}
style={{ width: `${(ms / scale) * 100}%`, background: s.color }}
className="h-full first:rounded-l-sm"
/>
)
})}
</div>
{t.error && (
<div className="absolute inset-0 flex items-center justify-center bg-rose-500/15 text-[10px] font-semibold text-rose-300">
Fehler
</div>
)}
</div>
<span className="w-12 shrink-0 text-right font-mono text-[11px] font-semibold tabular-nums text-foreground">
{t.error ? "–" : fmt(rowTotal(t))}
</span>
</div>
)
}
export function LatencyCard() {
const { data: turns } = useVoiceTrace(12)
const list = turns ?? []
const scale = Math.max(1, ...list.map((t) => rowTotal(t)))
const latest = list[0]
const lead = latest ? culprit(latest) : null
return (
<div className="mc-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Gauge className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Latenz je Turn</h2>
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
</div>
{latest ? (
<div className="mt-2 flex items-baseline gap-2">
<span className={`font-space text-3xl font-bold tracking-tight tabular-nums ${latest.error ? "text-rose-400" : "text-foreground"}`}>
{latest.error ? "Fehler" : fmt(rowTotal(latest))}
</span>
<span className="text-xs text-muted-foreground">
letzter Turn{!latest.error && lead && <> · Täter: <span style={{ color: lead.color }} className="font-semibold">{lead.label}</span> {fmt(lead.ms)}</>}
</span>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">Noch kein Voice-Turn aufgezeichnet.</div>
)}
{latest && !latest.error && (
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1 pt-1">
{SEGMENTS.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
</div>
))}
</div>
</div>
{list.length > 0 ? (
<div className="space-y-1.5">
{list.map((t) => <TurnRow key={t.id} t={t} scale={scale} />)}
</div>
) : (
<div className="flex h-[120px] items-center justify-center px-6 text-center text-xs text-muted-foreground">
Sprich einmal mit Lucy — dann erscheint hier der Zeit-Wasserfall pro Turn, damit man den einen Hänger sofort sieht.
</div>
)}
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
„Hirn" = Zeit bis zum ersten Wort (Agent + Gedächtnis-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
</div>
</div>
)
}
@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import { LiveAreaChart } from "./LiveAreaChart"
import type { ChartSeries } from "./chartTypes"
const SERIEN: ChartSeries[] = [{ key: "cpu", label: "CPU", color: "#2dd4bf" }]
const DATEN = [
{ t: 1_700_000_000_000, cpu: 12 },
{ t: 1_700_000_003_000, cpu: 34 },
]
// Die Lazy-Huelle ist der Grund, warum Recharts nicht mehr im Startbuendel liegt (P0).
// Wichtig ist dabei nur eines: Der Platzhalter muss GENAU so hoch sein wie das spaetere
// Diagramm — sonst springt die Karte beim Nachladen.
describe("LiveAreaChart (Lazy-Huelle)", () => {
it("haelt vor dem Nachladen die volle Diagrammhoehe frei", () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
const platzhalter = container.querySelector<HTMLElement>('[aria-hidden="true"]')
expect(platzhalter).not.toBeNull()
expect(platzhalter!.style.height).toBe("176px")
})
it("uebernimmt eine abweichende Hoehe", () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={96} />)
expect(container.querySelector<HTMLElement>('[aria-hidden="true"]')!.style.height).toBe("96px")
})
it("laedt die Recharts-Umsetzung nach und zeigt sie an", async () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
await waitFor(() => {
expect(container.querySelector(".recharts-responsive-container")).not.toBeNull()
})
// Platzhalter ist verschwunden, sobald das Diagramm da ist.
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
it("ist fuer Screenreader stumm — der Platzhalter ist reine Optik", () => {
render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
expect(screen.queryByText(/l(ä|ae)dt/i)).toBeNull()
})
})
@@ -1,33 +0,0 @@
import { Suspense, lazy } from "react"
import type { LiveAreaChartProps } from "./chartTypes"
export type { ChartSeries } from "./chartTypes"
// Lazy-Hülle um die Recharts-Umsetzung (v3-Umbau P0).
//
// WARUM: Das Cockpit ist die Startseite und wird deshalb NICHT lazy geladen. Über
// SystemStatusCard / TokenPerformanceCard zog es Recharts samt d3-Abhängigkeiten in das
// Start-Bündel — gemessen ~95 kB gzip von 220 kB, für vier Flächendiagramme, die man erst
// sieht, wenn die Seite längst steht. Jetzt kommt Recharts als eigener Chunk nach.
//
// Der Platzhalter hat exakt die Höhe des Diagramms (Prop `height`), damit beim Nachladen
// nichts springt — die Karte ist von der ersten Zeichnung an so hoch wie am Ende.
const Impl = lazy(() => import("./LiveAreaChartImpl"))
export function LiveAreaChart(props: LiveAreaChartProps) {
return (
<Suspense fallback={<ChartPlatzhalter height={props.height ?? 176} />}>
<Impl {...props} />
</Suspense>
)
}
// Ruhiges Skelett statt Spinner: im LAN ist der Chunk in Millisekunden da, ein blinkender
// Lade-Kringel wäre nur Unruhe. Die Linie deutet die Grundachse des Diagramms an.
function ChartPlatzhalter({ height }: { height: number }) {
return (
<div style={{ height }} className="flex w-full items-end" aria-hidden="true">
<div className="h-px w-full bg-border/40" />
</div>
)
}
@@ -1,105 +0,0 @@
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import type { LiveAreaChartProps } from "./chartTypes"
// Recharts-Umsetzung des Verlaufsgraphen. Diese Datei ist der EINZIGE Ort im Projekt, der
// Recharts importiert — sie wird ausschließlich per lazy() aus LiveAreaChart.tsx geladen und
// landet dadurch in einem eigenen Chunk statt im Start-Bündel (v3-Umbau P0).
const GRID = "rgba(130,130,150,0.14)"
const AXIS = "rgba(130,130,150,0.85)"
function niceCeil(v: number): number {
if (v <= 0) return 10
const mag = Math.pow(10, Math.floor(Math.log10(v)))
const n = v / mag
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
return step * mag
}
function fmt(v: number, unit: string): string {
const n = unit === "%" ? Math.round(v) : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toString()
return `${n}${unit}`
}
function ChartTooltip({ active, payload, unit, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
{Number.isFinite(label) && (
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
)}
<div className="space-y-1">
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
<span className="uppercase tracking-wider text-muted-foreground">{p.name}</span>
<span className="ml-auto pl-3 font-bold tabular-nums text-foreground">{fmt(p.value, unit)}</span>
</div>
))}
</div>
</div>
)
}
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
export default function LiveAreaChartImpl({
data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
}: LiveAreaChartProps) {
const peak = data.reduce(
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
)
const yMax = yMode === "percent"
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
: Math.max(niceCeil(peak * 1.15), 10)
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
const fmtTime = (v: number) =>
new Date(v).toLocaleTimeString("de-DE",
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
: { hour: "2-digit", minute: "2-digit" })
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 6, bottom: 0, left: -12 }}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke={GRID} />
{showTime ? (
<XAxis
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
/>
) : (
<XAxis dataKey="t" hide />
)}
<YAxis
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
/>
<Tooltip content={<ChartTooltip unit={unit} />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
{series.map((s) => (
<Area
key={s.key} type="monotone" dataKey={s.key} name={s.label}
stroke={s.color} strokeWidth={2} fill={`url(#grad-${s.key})`}
dot={false} activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={false} connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)
}
@@ -1,27 +0,0 @@
import { cn } from "@/lib/utils"
// Bereichs-Schalter der Leistungs-Karten: Live (2-min-Browser-Store) vs. 1h/24h
// (Backend-Ringpuffer, /api/system/history). Bewusst winzig — kein Chart-Gebirge.
export type ChartRange = "live" | "1h" | "24h"
export const RANGE_MINUTES: Record<Exclude<ChartRange, "live">, number> = { "1h": 60, "24h": 1440 }
const LABELS: Record<ChartRange, string> = { live: "Live", "1h": "1 h", "24h": "24 h" }
export function RangeSwitch({ value, onChange }: { value: ChartRange; onChange: (r: ChartRange) => void }) {
return (
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
{(Object.keys(LABELS) as ChartRange[]).map((r) => (
<button
key={r}
onClick={() => onChange(r)}
className={cn(
"rounded-md px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide transition-all cursor-pointer",
value === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{LABELS[r]}
</button>
))}
</div>
)
}
@@ -1,89 +0,0 @@
import { useMemo, useState } from "react"
import { Cpu } from "lucide-react"
import { useSystemHistory } from "@/lib/useSystemHistory"
import { useMetricHistory } from "@/lib/queries"
import { gb } from "@/lib/format"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
const SERIES: ChartSeries[] = [
{ key: "cpu", label: "CPU", color: "#2dd4bf" },
{ key: "ram", label: "RAM", color: "#38bdf8" },
{ key: "gpu", label: "GPU", color: "#a78bfa" },
{ key: "disk", label: "Disk", color: "#fbbf24" },
]
export function SystemStatusCard() {
const { sys, hist } = useSystemHistory()
const [range, setRange] = useState<ChartRange>("live")
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
// Live = 3-s-Browser-Store (~2 min); 1h/24h = Backend-Ringpuffer mit echten Zeitstempeln.
const chartData = useMemo(() => {
if (range === "live") return hist
return (histQ.data?.points ?? []).map((p) => ({ t: p.t * 1000, cpu: p.cpu, ram: p.ram, gpu: p.gpu, disk: p.disk }))
}, [range, hist, histQ.data])
const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null)
const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu)
const current: Record<string, number | null | undefined> = {
cpu: sys?.cpu?.percent, ram: sys?.ram?.percent,
gpu: hasGpu ? sys!.gpu!.busy_percent : null, disk: sys?.disk?.percent,
}
const detail: Record<string, string> = {
cpu: sys?.cpu?.cores ? `${sys.cpu.cores} Cores` : "",
ram: sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} GB` : "",
gpu: hasGpu ? `${gb(sys!.gpu!.gtt_used!)}/${gb(sys!.gpu!.gtt_total!)} GB` : "",
disk: sys?.disk ? `${gb(sys.disk.used)}/${gb(sys.disk.total)} GB` : "",
}
return (
<div className="flex flex-col justify-between mc-card p-5">
<div>
<div className="mb-3 flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2>
{range === "live" && (
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
)}
<div className="ml-auto"><RangeSwitch value={range} onChange={setRange} /></div>
</div>
{sys ? (
<>
<div className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1.5">
{activeSeries.map((s) => (
<div key={s.key} className="flex min-w-0 items-center gap-1.5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} />
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
<span className="shrink-0 font-mono text-xs font-bold tabular-nums text-foreground">{Math.round(current[s.key] ?? 0)}%</span>
{detail[s.key] && <span className="truncate font-mono text-[10px] text-muted-foreground/60">{detail[s.key]}</span>}
</div>
))}
</div>
{range !== "live" && chartData.length === 0 && (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
</div>
)}
{(range === "live" || chartData.length > 0) && (
<LiveAreaChart data={chartData} series={activeSeries} unit="%" yMode="percent" height={176} showTime />
)}
</>
) : (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten…</div>
)}
</div>
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
<div className="mt-3 flex gap-4 border-t border-border/30 pt-3 font-mono text-xs text-muted-foreground/80">
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
</div>
)}
</div>
)
}
@@ -1,103 +0,0 @@
import { useMemo, useState } from "react"
import { Activity } from "lucide-react"
import { useTokenStats, useMetricHistory } from "@/lib/queries"
import { useTokHistory } from "@/lib/metricsStore"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
const SERIES: ChartSeries[] = [
{ key: "prompt", label: "Prompt", color: "#f59e0b" },
{ key: "completion", label: "Antwort", color: "#2dd4bf" },
]
export function TokenPerformanceCard() {
const { data: ts } = useTokenStats()
const hist = useTokHistory() // Live-Durchsatz aus dem modul-globalen Store (~2 min)
const [range, setRange] = useState<ChartRange>("live")
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
// 1h/24h: Raten aus den Deltas der Gesamtzähler (tok/s zwischen zwei Sample-Punkten).
const chartData = useMemo(() => {
if (range === "live") return hist
const pts = histQ.data?.points ?? []
const out: { t: number; prompt: number; completion: number }[] = []
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i]
if (a.tp == null || b.tp == null || a.tc == null || b.tc == null) continue
const dt = Math.max(b.t - a.t, 1)
out.push({
t: b.t * 1000,
prompt: Math.max(0, (b.tp - a.tp) / dt),
completion: Math.max(0, (b.tc - a.tc) / dt),
})
}
return out
}, [range, hist, histQ.data])
const last = hist[hist.length - 1]
const curRate = last ? Math.round(last.prompt + last.completion) : 0
return (
<div className="mc-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Activity className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</h2>
{range === "live" && (
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
)}
</div>
{ts && (
<div className="mt-2 flex items-baseline gap-2">
<span className="font-space text-3xl font-bold tracking-tight text-foreground tabular-nums">
{curRate.toLocaleString("de-DE")}
</span>
<span className="text-xs text-muted-foreground">tok/s aktuell</span>
</div>
)}
{ts && (
<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>
<div className="flex shrink-0 flex-col items-end gap-2 pt-1">
<RangeSwitch value={range} onChange={setRange} />
<div className="flex flex-col items-end gap-1.5">
{SERIES.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
{Math.round((last?.[s.key as "prompt" | "completion"]) ?? 0)}
</span>
</div>
))}
</div>
</div>
</div>
{range !== "live" && chartData.length === 0 ? (
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
</div>
) : ts ? (
<LiveAreaChart data={chartData} series={SERIES} unit=" tok/s" yMode="auto" height={150} showTime />
) : (
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz…</div>
)}
<div className="mt-2 border-t border-border/30 pt-2 text-[10px] text-muted-foreground/70">
Durchsatz aus dem Gateway abgeleitet (Prefill vs. Generierung). Flach bei Leerlauf.
</div>
</div>
)
}
@@ -1,15 +0,0 @@
// Typen der Verlaufs-Diagramme. Bewusst eine eigene, winzige Datei: sie wird sowohl vom
// Lazy-Wrapper (LiveAreaChart.tsx) als auch von der Recharts-Umsetzung (LiveAreaChartImpl.tsx)
// gebraucht. Läge sie in einer der beiden, würde ein Typ-Import die Datei aneinander binden —
// und genau das soll die Aufteilung ja verhindern.
export type ChartSeries = { key: string; label: string; color: string }
export interface LiveAreaChartProps {
data: any[]
series: ChartSeries[]
unit?: string
yMode?: "percent" | "auto"
height?: number
showTime?: boolean
}
@@ -1,68 +0,0 @@
import { api } from "@/lib/api"
import { useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { fmtBytes, fmtEta } from "@/lib/format"
export function JobsBar() {
const qc = useQueryClient()
const { data: jobs = [] } = useJobs()
const { showAlert, dialogElement } = useDialog()
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
qc.invalidateQueries({ queryKey: qk.jobs })
} catch (e: any) {
showAlert("Fehler", e.message)
}
}
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null
return (
<div className="space-y-3 mc-card p-4">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => (
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground font-mono">
{j.progress ?? 0}% • {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
</span>
<button
onClick={() => cancelJob(j.id)}
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
>
Abbrechen
</button>
</div>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
</div>
</div>
))}
{recent.map((j) => (
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
<span className="truncate">{j.label}</span>
<span className={cn(
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
)}>
{j.state}
</span>
</div>
))}
{dialogElement}
</div>
)
}
@@ -1,181 +0,0 @@
import { useEffect, useState } from "react"
import { Sliders, RotateCcw, Check, Loader2, MessageSquare, Code2 } from "lucide-react"
import { updateRoutingPolicy, type RoutingPolicy } from "@/lib/api"
import { useRoutingPolicy, useQueryClient, qk } from "@/lib/queries"
import { cn } from "@/lib/utils"
// Welche Policy-Felder zu welcher Lane gehören (Rest ist global).
const CHAT_FIELDS: (keyof RoutingPolicy)[] = ["fast", "heavy", "heavy_chars"]
const CODING_FIELDS: (keyof RoutingPolicy)[] = ["coder_lite", "coder", "coding_escalate_chars"]
export function LaneEditor() {
const qc = useQueryClient()
const { data: meta, isLoading } = useRoutingPolicy()
const [draft, setDraft] = useState<RoutingPolicy | null>(null)
const [saving, setSaving] = useState(false)
const [err, setErr] = useState("")
const [savedAt, setSavedAt] = useState(0)
// Draft initialisieren, sobald die Policy geladen ist (und nicht überschreiben, wenn schon editiert).
useEffect(() => {
if (meta?.policy && !draft) setDraft({ ...meta.policy })
}, [meta, draft])
if (isLoading || !meta || !draft) {
return (
<div className="mc-card p-5 text-xs text-muted-foreground">
Lade Routing-Policy…
</div>
)
}
const fieldSpec = (key: keyof RoutingPolicy) => meta.fields.find((f) => f.key === key)!
const dirty = (Object.keys(draft) as (keyof RoutingPolicy)[]).some((k) => draft[k] !== meta.policy[k])
const set = <K extends keyof RoutingPolicy>(key: K, value: RoutingPolicy[K]) => {
setDraft((d) => (d ? { ...d, [key]: value } : d))
setErr("")
}
const resetField = (key: keyof RoutingPolicy) => set(key, meta.defaults[key])
async function save() {
if (!draft) return
const patch: Partial<RoutingPolicy> = {}
for (const k of Object.keys(draft) as (keyof RoutingPolicy)[]) {
if (draft[k] !== meta!.policy[k]) (patch as any)[k] = draft[k]
}
if (Object.keys(patch).length === 0) return
setSaving(true)
setErr("")
try {
const { policy } = await updateRoutingPolicy(patch)
setDraft({ ...policy })
qc.invalidateQueries({ queryKey: qk.routingPolicy })
qc.invalidateQueries({ queryKey: qk.routing })
setSavedAt(Date.now())
setTimeout(() => setSavedAt(0), 2000)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setSaving(false)
}
}
function Field({ k }: { k: keyof RoutingPolicy }) {
const spec = fieldSpec(k)
const val = draft![k]
const isDefault = draft![k] === meta!.defaults[k]
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-[10px] font-semibold text-muted-foreground">{spec.label}</label>
{!isDefault && (
<button
onClick={() => resetField(k)}
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/60 hover:text-foreground transition-colors cursor-pointer"
title={`Auf Default zurücksetzen (${String(meta!.defaults[k]) || "leer"})`}
>
<RotateCcw className="h-2.5 w-2.5" /> Default
</button>
)}
</div>
{spec.type === "bool" ? (
<button
onClick={() => set(k, !val as any)}
className={cn(
"flex h-8 w-full items-center justify-between rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
val ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" : "border-border/40 bg-background/40 text-muted-foreground",
)}
>
<span>{val ? "An" : "Aus"}</span>
<span className={cn("h-3.5 w-3.5 rounded-full transition-colors", val ? "bg-emerald-400" : "bg-muted-foreground/40")} />
</button>
) : spec.type === "int" ? (
<input
type="number"
value={val as number}
min={spec.min}
max={spec.max}
aria-label={spec.label}
onChange={(e) => set(k, Number(e.target.value) as any)}
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
/>
) : (
<input
type="text"
value={val as string}
aria-label={spec.label}
spellCheck={false}
placeholder={k === "coder_lite" ? "(leer = aus)" : ""}
onChange={(e) => set(k, e.target.value as any)}
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
/>
)}
</div>
)
}
return (
<div className="mc-card p-5 space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Sliders className="h-4.5 w-4.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Lane-Routing &amp; Policy</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 border border-emerald-500/20">hot-reload</span>
</div>
<div className="flex items-center gap-2">
{err && <span className="text-[10px] text-red-400 max-w-[280px] truncate" title={err}>{err}</span>}
{savedAt > 0 && (
<span className="flex items-center gap-1 text-[10px] text-emerald-400"><Check className="h-3.5 w-3.5" /> gespeichert</span>
)}
<button
onClick={save}
disabled={!dirty || saving}
className={cn(
"h-8 px-4 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all flex items-center gap-1.5",
dirty && !saving
? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer shadow-md shadow-primary/10"
: "bg-background/40 text-muted-foreground/50 border border-border/40 cursor-not-allowed",
)}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Speichern
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-relaxed -mt-1">
Welches echte Modell hinter den virtuellen Lanes <code className="text-cyan-300">chat</code> und{" "}
<code className="text-cyan-300">coding</code> steckt. Änderungen greifen sofort (kein Neustart). Die
Keyword-Heuristiken bleiben im Code.
</p>
<div className="grid gap-4 md:grid-cols-2">
{/* chat-Lane */}
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
<MessageSquare className="h-4 w-4 text-teal-400" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">chat</span>
<span className="text-[9px] text-muted-foreground/60 font-mono">(= auto)</span>
</div>
{CHAT_FIELDS.map((k) => <Field key={k} k={k} />)}
</div>
{/* coding-Lane */}
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
<Code2 className="h-4 w-4 text-indigo-400" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">coding</span>
<span className="text-[9px] text-muted-foreground/60 font-mono">(agentisch → immer Coder)</span>
</div>
{CODING_FIELDS.map((k) => <Field key={k} k={k} />)}
</div>
</div>
{/* Globale Schalter */}
<div className="rounded-xl border border-border/40 bg-background/25 p-4">
<div className="max-w-xs"><Field k="fast_no_think" /></div>
</div>
</div>
)
}
@@ -1,60 +0,0 @@
import { type Fit } from "@/lib/api"
import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, Workbench, …) stabil.
export { ROLES, roleTone, roleMeta }
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
// `dense` = nur Kurzform (für enge Badges).
export function RoleLabel({
role,
dense = false,
className,
}: {
role?: string | null
dense?: boolean
className?: string
}) {
const meta = roleMeta(role)
const Icon = meta.icon
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
meta.tone,
className,
)}
title={`${meta.desc} (${meta.role})`}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{dense ? meta.short : meta.label}
</span>
)
}
export function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
}[fit.level]
return (
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
{fit.text} • {fit.req_gb} GB RAM
</span>
)
}
export function getBrandInfo(name: string) {
const low = name.toLowerCase()
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
}
@@ -1,171 +0,0 @@
import { useState } from "react"
import { X, Check, Zap, AlertTriangle } from "lucide-react"
import { api, type ModelInfo, type DraftInfo } from "@/lib/api"
import { useDrafts } from "@/lib/queries"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
/**
* Idiotensichere Speculative-Decoding-Konfiguration für EIN Modell.
* Zeigt nur VOCAB-KOMPATIBLE Drafts als wählbar; inkompatible werden gesperrt
* und mit Begründung angezeigt. So kann nie ein kaputter Draft gesetzt werden
* (der das Modell beim Laden scheitern ließe).
*/
export function SpecDraftModal({
model, onClose, onChanged,
}: { model: ModelInfo; onClose: () => void; onChanged: () => void }) {
const { data, isLoading } = useDrafts(model.gguf_path)
const [busy, setBusy] = useState<string | null>(null)
const [err, setErr] = useState("")
const tv = data?.target_vocab
const drafts = data?.drafts ?? []
const compatibles = drafts.filter((d) => d.compatible === true)
const currentFile = model.spec_draft_model
async function apply(draftPath: string | null) {
setBusy(draftPath ?? "__clear__")
setErr("")
try {
await api(`/api/models/${encodeURIComponent(model.name)}/draft`, {
method: "POST",
body: JSON.stringify({ draft_path: draftPath }),
})
onChanged()
onClose()
} catch (e: any) {
setErr(String(e?.message || e))
setBusy(null)
}
}
const vocabLabel = (v?: { pre: string | null; n_vocab: number | null } | null) =>
v ? `${v.pre ?? "?"} · ${v.n_vocab?.toLocaleString() ?? "?"} Tokens` : "—"
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-2">
<Zap className="h-4 w-4" /> Speculative Draft
</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
<div className="text-xs text-muted-foreground leading-relaxed">
Beschleunigt die Token-Generierung mit einem kleinen "Draft"-Modell. Voraussetzung:
der Draft muss den <strong className="text-foreground">exakt gleichen Tokenizer (Vocab)</strong> haben
wie das Modell — sonst lehnt llama.cpp es ab.
</div>
{/* Ziel-Vocab */}
<div className="rounded-lg border border-border/40 bg-background/30 px-3 py-2 text-[11px] font-mono flex items-center justify-between">
<span className="text-muted-foreground">{model.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
<span className="text-foreground">Vocab: {vocabLabel(tv)}</span>
</div>
{/* Aktueller Zustand */}
{model.spec_active && currentFile && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 flex items-center justify-between gap-2">
<span className="text-[11px] text-emerald-400 font-mono flex items-center gap-1.5 truncate">
<Check className="h-3.5 w-3.5 shrink-0" /> Aktiv: {currentFile}
</span>
<button
onClick={() => apply(null)}
disabled={busy !== null}
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase shrink-0 cursor-pointer disabled:opacity-50"
>
Deaktivieren
</button>
</div>
)}
{!data?.target_exists && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-400 flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Modell-GGUF noch nicht vorhanden — Spec-Draft ist nach dem Download konfigurierbar.
</div>
)}
{/* Draft-Liste */}
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{isLoading ? (
<div className="text-xs text-muted-foreground py-6 text-center">Prüfe Vocab-Kompatibilität…</div>
) : drafts.length === 0 ? (
<div className="text-[11px] text-muted-foreground border border-dashed border-border/50 rounded-lg p-4 text-center">
Keine Draft-Modelle in <code className="font-mono">/srv/models/drafts</code>.
Lade ein kleines same-family-Modell (z.B. Qwen3-0.6B) und lege es dort ab.
</div>
) : (
drafts.map((d: DraftInfo) => {
const isCurrent = d.filename === currentFile
const ok = d.compatible === true
return (
<div
key={d.path}
className={cn(
"rounded-lg border px-3 py-2 flex items-center justify-between gap-2 text-left",
ok ? "border-border/40 bg-background/20" : "border-border/20 bg-background/10 opacity-60",
isCurrent && "border-primary/40 bg-primary/10",
)}
>
<div className="min-w-0">
<div className="text-[11px] font-mono font-semibold text-foreground truncate flex items-center gap-1.5">
{d.filename}
{d.mtp && (
<span
className="shrink-0 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-wider text-indigo-400 border border-indigo-500/25"
title="MTP-Kopf (Multi-Token-Prediction) — by-construction vocab-identisch, höchste Akzeptanzrate"
>
MTP
</span>
)}
</div>
<div className="text-[9px] text-muted-foreground font-mono">
{fmtSize(d.size_bytes)} · Vocab: {vocabLabel(d.vocab)}
</div>
</div>
{ok ? (
isCurrent ? (
<span className="text-[9px] font-bold uppercase text-primary flex items-center gap-1 shrink-0"><Check className="h-3.5 w-3.5" /> Aktiv</span>
) : (
<button
onClick={() => apply(d.path)}
disabled={busy !== null}
className="h-7 px-3 rounded-lg border border-emerald-500/30 bg-emerald-500/5 hover:bg-emerald-500/15 text-emerald-400 text-[9px] font-bold uppercase shrink-0 cursor-pointer disabled:opacity-50"
>
Aktivieren
</button>
)
) : (
<span
className="text-[9px] font-bold uppercase text-amber-400/80 flex items-center gap-1 shrink-0"
title={d.compatible === false
? `Inkompatibel: Tokenizer/Vocab weicht ab (Draft ${d.vocab?.pre}/${d.vocab?.n_vocab} ≠ Modell ${tv?.pre}/${tv?.n_vocab}).`
: "Kompatibilität nicht prüfbar (Datei fehlt)."}
>
<AlertTriangle className="h-3.5 w-3.5" /> {d.compatible === false ? "Vocab ≠" : "n/a"}
</span>
)}
</div>
)
})
)}
</div>
{/* Hinweis, wenn Drafts da sind aber keiner kompatibel */}
{!isLoading && data?.target_exists && drafts.length > 0 && compatibles.length === 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-400 leading-relaxed">
Kein vocab-kompatibler Draft verfügbar — Speculative Decoding ist für dieses Modell nicht möglich.
Es braucht einen Draft mit identischem Tokenizer (pre=<span className="font-mono">{tv?.pre}</span>,
n_vocab=<span className="font-mono">{tv?.n_vocab?.toLocaleString()}</span>).
</div>
)}
{err && <div className="text-[10px] text-red-400 font-mono">{err}</div>}
</div>
</div>
)
}
@@ -1,203 +0,0 @@
import { useState } from "react"
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
import { setGroup, type ModelInfo } from "@/lib/api"
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() {
const qc = useQueryClient()
const { data: modelsResp } = useModels()
const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain()
const { showAlert, dialogElement } = useDialog()
const [picking, setPicking] = useState(false)
const [busy, setBusy] = useState(false)
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const group = groupsResp?.groups?.brains
const members = group?.members ?? []
const byName = (name: string) => models.find((m) => m.name === name)
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
const B = 1024 ** 3
const bud = brain?.budget
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
const reservedGb = bud?.warm_projected_gb ?? weightsGb
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
async function save(next: string[]) {
setBusy(true)
try {
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups })
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.hermesBrain })
} catch (e: any) {
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
} finally {
setBusy(false)
}
}
function remove(name: string) {
const m = byName(name)
if (m && roleMeta(m.role).protected) {
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
return
}
void save(members.filter((x) => x !== name))
}
function add(name: string) {
setPicking(false)
void save([...members, name])
}
if (!group) {
return (
<div className="mc-card p-5">
<Header />
<p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
</p>
{dialogElement}
</div>
)
}
return (
<div className="mc-card p-5 space-y-4">
<Header />
{/* Mitglieder */}
<div className="space-y-1.5">
{memberModels.length === 0 && (
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
)}
{memberModels.map((m) => {
const isWarm = running.includes(m.name)
const prot = roleMeta(m.role).protected
return (
<div key={m.name} className="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.5">
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</div>
{prot ? (
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
) : (
<button
onClick={() => remove(m.name)}
disabled={busy}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</div>
{/* Hinzufügen */}
<button
onClick={() => setPicking(true)}
disabled={busy || eligible.length === 0}
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
</button>
{/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span>
<span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
</div>
{bud && !bud.fits && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
bleibt dabei immer geladen — wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
statt das Set zu verdrängen).
</span>
</p>
)}
{bud && bud.fits && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell — nichts wird verdrängt.
</p>
)}
</div>
{/* Auswahl-Modal */}
{picking && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">Dauerhaft bereithalten</h3>
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
{eligible.map((m) => (
<button
key={m.name}
onClick={() => add(m.name)}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
>
<span className="flex min-w-0 items-center gap-2">
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</button>
))}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
function Header() {
return (
<div className="flex items-center gap-2">
<Zap className="h-4.5 w-4.5 text-primary" />
<div>
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit — sie antworten ohne Ladezeit.</p>
</div>
</div>
)
}
@@ -1,119 +0,0 @@
import { useMemo, useRef, useState } from "react"
import { AlertTriangle, RefreshCw, Search, Terminal } from "lucide-react"
import { cn } from "@/lib/utils"
// System-Log-Konsole (Paket D16c): färbt Fehler/Warnungen ein, filtert auf „nur Probleme"
// und durchsucht die Zeilen. Ersetzt den früheren monochromen <pre>-Dump („zu dünn").
type Level = "error" | "warn" | "info"
// Wortgrenzen-Regex, damit „error" nicht in jeder URL/jedem Pfad anschlägt. Deutsch + Englisch,
// weil unsere Dienste beides loggen (Hermes englisch, MC2/Voice teils deutsch).
const _ERR = /\b(error|fehler|fail(ed|ure)?|fatal|critical|crit|panic|traceback|exception|denied|refused|unable|emerg|alert|oom|killed)\b/i
const _WARN = /\b(warn(ing)?|deprecat\w*|retry(ing)?|timeout|timed out|degraded|missing|not found|nicht gefunden)\b/i
function classify(line: string): Level {
if (_ERR.test(line)) return "error"
if (_WARN.test(line)) return "warn"
return "info"
}
const LEVEL_CLS: Record<Level, string> = {
error: "text-red-400",
warn: "text-amber-300",
info: "text-cyan-300/80",
}
export function LogConsole({ service, text, loading, logStatus, onRefresh, onOpenSettings }: {
service: string
text: string
loading: boolean
logStatus: string | null
onRefresh: () => void
onOpenSettings: () => void
}) {
const [problemsOnly, setProblemsOnly] = useState(false)
const [query, setQuery] = useState("")
const containerRef = useRef<HTMLDivElement | null>(null)
const lines = useMemo(
() => (text ? text.split("\n").map((t) => ({ t, level: classify(t) })) : []),
[text],
)
const problemCount = useMemo(() => lines.filter((l) => l.level !== "info").length, [lines])
const q = query.trim().toLowerCase()
const shown = lines.filter(
(l) => (!problemsOnly || l.level !== "info") && (!q || l.t.toLowerCase().includes(q)),
)
const needsPw = logStatus === "password_required" || logStatus === "incorrect_password"
return (
<div className="flex-1 flex flex-col min-h-[300px] border border-border/60 bg-black/50 rounded-xl overflow-hidden shadow-inner">
{/* Konsolen-Kopf: Titel + Problem-Filter + Suche + Neu laden */}
<div className="flex h-10 items-center justify-between gap-2 px-3 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 shrink-0">
<Terminal className="h-3 w-3 text-primary" />
<span className="hidden sm:inline">stdout/stderr —</span> {service}
</div>
<div className="flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="filtern…"
className="h-7 w-24 sm:w-36 rounded-md border border-border/50 bg-background/40 pl-6 pr-2 text-[10px] text-foreground outline-none focus:border-primary/50"
/>
</div>
<button
onClick={() => setProblemsOnly((v) => !v)}
title="Nur Fehler und Warnungen zeigen"
className={cn("flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] font-semibold transition-colors",
problemsOnly ? "border-amber-500/50 bg-amber-500/15 text-amber-300"
: "border-border/50 bg-background/20 text-muted-foreground hover:text-foreground")}
>
<AlertTriangle className="h-3 w-3" />
Nur Probleme{problemCount > 0 ? ` (${problemCount})` : ""}
</button>
<button onClick={onRefresh} disabled={loading} title="Neu laden"
className="flex h-7 w-7 items-center justify-center rounded-md border border-border/50 bg-background/20 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</button>
</div>
</div>
{/* Zeilen-Bereich */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 text-[10px] font-mono leading-relaxed scrollbar-thin select-text">
{needsPw ? (
<div className="flex h-full flex-col items-center justify-center space-y-3 p-6 text-center">
<AlertTriangle className="h-8 w-8 text-amber-400 animate-pulse animate-duration-1000" />
<div className="text-xs font-semibold text-amber-300">
{logStatus === "incorrect_password" ? "Falsches Sudo-Passwort hinterlegt." : "Sudo-Passwort für systemd-Dienste erforderlich."}
</div>
<p className="max-w-xs text-[10px] leading-normal text-muted-foreground">
Für das Auslesen der systemd-Logs von {service} werden Root-Rechte benötigt. Hinterlege dein Passwort in den Einstellungen.
</p>
<button onClick={onOpenSettings}
className="mt-2 rounded-md bg-primary px-3 py-1.5 text-[10px] font-semibold text-primary-foreground transition-colors hover:bg-primary/95">
Sudo-Passwort eintragen
</button>
</div>
) : loading && !text ? (
<span className="text-muted-foreground">Lade Logs…</span>
) : lines.length === 0 ? (
<span className="text-muted-foreground">Keine Logeinträge vorhanden.</span>
) : shown.length === 0 ? (
<span className="text-muted-foreground">
{problemsOnly ? "Keine Fehler oder Warnungen — der Dienst läuft sauber. ✓" : "Kein Treffer für den Filter."}
</span>
) : (
shown.map((l, i) => (
<div key={i} className={cn("whitespace-pre-wrap", LEVEL_CLS[l.level])}>{l.t || " "}</div>
))
)}
</div>
</div>
)
}
@@ -1,182 +0,0 @@
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"
import { api } from "@/lib/api"
// Einstellungen-Tab des SystemDrawers.
//
// v3-Umbau P1 (28.08.2026): Hier standen zwei Geheimnisse, die im localStorage des
// Browsers lagen und bei jedem mutierenden Request mitreisten.
//
// · Das Box-Sudo-Passwort ist ERSATZLOS weg. Auf der Box gemessen: `sudo -n true`
// laeuft durch, weil /etc/sudoers den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt.
// Das Feld hat also nie etwas bewirkt — es war reines Risiko (ein XSS in dieser
// SPA haette Root bedeutet).
// · Der HuggingFace-Token liegt jetzt auf der Box (services/geheimnisse.py, 0600).
// Diese Ansicht erfaehrt nur noch, OB einer gesetzt ist — nie seinen Wert. Deshalb
// gibt es kein "Anzeigen" fuer einen gespeicherten Token, sondern nur Ersetzen
// oder Loeschen.
interface GeheimnisStatus {
hf_token_gesetzt: boolean
hf_token_aus_env: boolean
schreibbar: boolean
}
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [status, setStatus] = useState<GeheimnisStatus | null>(null)
const [eingabe, setEingabe] = useState("")
const [sichtbar, setSichtbar] = useState(false)
const [laedt, setLaedt] = useState(false)
const [arbeitet, setArbeitet] = useState(false)
async function statusLaden() {
setLaedt(true)
try {
setStatus(await api<GeheimnisStatus>("/api/maintenance/geheimnisse"))
} catch (e: any) {
showAlert("Nicht erreichbar", `Der Geheimnis-Zustand konnte nicht geladen werden: ${e?.message || e}`)
} finally {
setLaedt(false)
}
}
useEffect(() => {
if (open) {
setEingabe("")
setSichtbar(false)
void statusLaden()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
async function setzen(wert: string | null) {
setArbeitet(true)
try {
const r = await api<GeheimnisStatus>("/api/maintenance/geheimnisse", {
method: "POST",
body: JSON.stringify({ schluessel: "hf_token", wert }),
})
setStatus(r)
setEingabe("")
showAlert(wert ? "Gespeichert" : "Gelöscht",
wert ? "Der Token liegt jetzt auf der Box — der Browser behält ihn nicht."
: "Der Token wurde von der Box entfernt.")
} catch (e: any) {
showAlert("Fehlgeschlagen", e?.message || String(e))
} finally {
setArbeitet(false)
}
}
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten &amp; Schlüssel</h3>
<p className="text-[10px] leading-normal text-muted-foreground">
Geheimnisse liegen auf der Box (Datei mit Rechten 0600), nicht im Browser. Diese Seite
zeigt nur, ob etwas gesetzt ist — den Wert bekommt sie nie zurück.
</p>
</div>
{/* Sudo: bewusst nur noch eine Erklaerung, kein Eingabefeld. */}
<div className="mc-card-sm space-y-1.5 p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold">
<ShieldCheck className="h-4 w-4 text-emerald-400" aria-hidden="true" />
Box-Sudo-Passwort — nicht mehr nötig
</div>
<p className="text-[10px] leading-normal text-muted-foreground">
Systemdienste, OS-Update und Neustart laufen passwortlos (<code className="font-mono text-primary">NOPASSWD</code> in
<code className="font-mono"> /etc/sudoers</code>). Das frühere Feld hat nie etwas bewirkt und lag dabei
im Browser-Speicher — es ist am 28.08.2026 ersatzlos entfallen. Verlangt <code className="font-mono">sudo</code> auf
der Box je doch ein Passwort, sagt die Wartung das im Klartext; das ist dann dort zu lösen.
</p>
</div>
{/* HuggingFace-Token */}
<div className="space-y-2">
<label htmlFor="hf-token" className="flex items-center gap-1.5 text-xs font-semibold">
<Key className="h-4 w-4 text-violet-400" aria-hidden="true" />
HuggingFace-Token
</label>
<div className="flex items-center gap-2 text-[11px]">
{laedt ? (
<span className="flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> Zustand wird geladen …
</span>
) : status?.hf_token_gesetzt ? (
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
Gesetzt{status.hf_token_aus_env ? " (aus der Dienst-Umgebung)" : ""}
</span>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-muted-foreground/50" aria-hidden="true" />
Nicht gesetzt
</span>
)}
</div>
{status?.hf_token_aus_env && (
<p className="text-[9px] leading-normal text-amber-400/90">
Der Token kommt aus der Umgebung des Dienstes und hat Vorrang. Hier gespeicherte Werte
bleiben wirkungslos, solange <code className="font-mono">HF_TOKEN</code> gesetzt ist.
</p>
)}
{status && !status.schreibbar && (
<p className="text-[9px] leading-normal text-amber-400/90">
Die Ablage ist nicht beschreibbar — auf einem Entwicklungsrechner ohne den Modell-Ordner
der Box ist das normal. Speichern würde hier ins Leere laufen.
</p>
)}
<div className="relative">
<input
id="hf-token"
type={sichtbar ? "text" : "password"}
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={status?.hf_token_gesetzt ? "Neuen Token eingeben, um zu ersetzen" : "hf_…"}
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={() => setSichtbar(!sichtbar)}
aria-label={sichtbar ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
>
{sichtbar ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] leading-normal text-muted-foreground">
Nötig für Modelle mit Zugangsschranke. Wird beim Download als
<code className="font-mono"> HF_TOKEN</code> an den Job gereicht und verlässt die Box nicht.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setzen(eingabe.trim())}
disabled={arbeitet || !eingabe.trim()}
className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{arbeitet && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
Auf der Box speichern
</button>
<button
onClick={() => setzen(null)}
disabled={arbeitet || !status?.hf_token_gesetzt}
className="flex h-9 cursor-pointer items-center justify-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
>
Löschen
</button>
</div>
</div>
)
}
@@ -1,196 +0,0 @@
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Job, UpdateDetails } from "@/lib/api"
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
detail: UpdateDetailState
maintenanceJob?: Job
onClose: () => void
onApply: () => void
}) {
const d = detail.data
const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={onClose} className="flex h-7 w-7 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>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen…
</div>
) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
) : (
<>
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
{d?.action_needed != null && (
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
{d.action_needed
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
</div>
{d.action_needed && d.action_text && (
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
)}
</div>
</div>
)}
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
{d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</div>
)}
{detail.kind === "os" ? (
<>
{(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
<div className="space-y-1">
{d!.packages!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
</span>
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
</span>
</div>
))}
</div>
</>
)}
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
{(d?.held_back?.length ?? 0) > 0 && (
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt — kein Handeln nötig
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
selbst — das ist kein Fehler und nichts hängt fest.
</p>
<div className="space-y-1">
{d!.held_back!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
</span>
<span className="text-[9px] text-muted-foreground shrink-0">
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
</span>
</div>
))}
</div>
</div>
)}
</>
) : detail.kind === "engine" || detail.kind === "swap" ? (
<>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
</div>
{(d?.name || d?.latest_tag) && (
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
)}
{d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<details className="group">
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
<pre className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
</details>
)}
</>
) : (
// hermes
(d?.commits?.length ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
<div className="space-y-1">
{d!.commits!.map((c) => (
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-[11px] truncate">{c.subject}</div>
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
</div>
</div>
))}
</div>
</>
)
)}
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
{detail.kind !== "os" && !nothing && (
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-muted-foreground leading-normal">
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
Stack per echter Anfrage — läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
</p>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button>
</div>
</div>
</div>
)
}
-44
View File
@@ -1,44 +0,0 @@
import { RefreshCw, FileText } from "lucide-react"
import { cn } from "@/lib/utils"
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
// Die frühere SERVICES-Konstante (UI-Kopie der Dienste-Liste) ist weg: der Drawer rendert
// jetzt direkt die Backend-Antwort von /api/system/services — EINE Quelle der Wahrheit.
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
@@ -1,12 +0,0 @@
import type { LucideIcon } from "lucide-react"
// Kleine Abschnitts-Überschrift innerhalb einer Seite. Lag vorher in AuftragsbuchView;
// seit der Ideen-Queue eine eigene Seite hat (25.07.2026) brauchen beide sie — also
// einmal hier statt zweimal dupliziert.
export function SectionLabel({ icon: Icon, children }: { icon: LucideIcon; children: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" /> {children}
</p>
)
}
+54 -32
View File
@@ -1,38 +1,60 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
type Variant = "default" | "outline" | "ghost"
type Size = "default" | "sm" | "icon"
// shadcn-Knopf im Cockpit-Stil: Instrumenten-Schrift, mindestens 44 px hoch (Touch am Handy).
// default = Bernstein (die eine Hauptaktion), outline = neutral, info = Cyan, gefahr = Rot.
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent font-anzeige font-semibold uppercase tracking-[0.12em] whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-bernstein text-primary-foreground hover:bg-bernstein-hell",
outline: "border-linie-stark bg-erhaben text-foreground hover:border-text-3 hover:bg-[#232931]",
info: "border-cyan-rand bg-transparent text-cyan hover:bg-cyan-grund",
gefahr: "border-rot-rand bg-rot-grund text-rot-text hover:bg-[#3a1512]",
ghost: "text-text-2 hover:bg-erhaben hover:text-foreground",
link: "px-0 text-cyan normal-case tracking-normal underline-offset-4 hover:underline",
},
size: {
default: "h-11 px-5 text-[15px]",
sm: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
icon: "size-11",
// Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch.
"icon-sm": "size-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
const variants: Record<Variant, string> = {
default: "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
ghost: "bg-transparent hover:bg-accent hover:text-accent-foreground",
}
const sizes: Record<Size, string> = {
default: "h-9 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
icon: "h-9 w-9",
}
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
),
)
Button.displayName = "Button"
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }
+166
View File
@@ -0,0 +1,166 @@
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Schließen</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Schließen</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as SheetPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Schließen</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}