feat: lower memory dedupe threshold for more aggressive cleaning
This commit is contained in:
@@ -1,38 +1,38 @@
|
||||
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 Three.js-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>
|
||||
)
|
||||
}
|
||||
}
|
||||
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 Three.js-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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{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>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,172 +1,172 @@
|
||||
import { useState } from "react"
|
||||
import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
export function AgentStatusCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: agent } = useAgentStatus(3_000)
|
||||
const { data: modelsData } = useModels()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
||||
|
||||
const models = modelsData?.models ?? []
|
||||
|
||||
async function changeBrainModel(model: string) {
|
||||
try {
|
||||
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
|
||||
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
|
||||
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
|
||||
</div>
|
||||
{agent?.terminal_url && (
|
||||
<a
|
||||
href={resolveExternalUrl(agent.terminal_url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
|
||||
agent.terminal_reachable
|
||||
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
|
||||
: "border border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> Terminal öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Terminal</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.terminal_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.terminal_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowBrainSelect(true)}
|
||||
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span>
|
||||
<Cpu className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-1 font-mono text-primary truncate flex items-center gap-1">
|
||||
<Layers className="h-3 w-3 shrink-0" />
|
||||
{agent.brain_model || "auto"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span>
|
||||
<Wrench className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono mt-1 space-y-0.5 text-muted-foreground">
|
||||
<div>Config: {agent.has_config ? <span className="text-emerald-400">✓</span> : "—"}</div>
|
||||
<div>Skills: {agent.has_skills ? <span className="text-emerald-400">✓</span> : "—"}</div>
|
||||
<div>Memory: {agent.has_memories ? <span className="text-emerald-400">✓</span> : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status…</div>
|
||||
)}
|
||||
</div>
|
||||
{agent && (
|
||||
<div className="mt-3 border-t border-border/30 pt-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>Telegram</span>
|
||||
<span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}>
|
||||
{agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>MCP-Server</span>
|
||||
<span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>PC Executor</span>
|
||||
<span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}>
|
||||
{agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent && showBrainSelect && (
|
||||
<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-md 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-1.5">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span>Hermes-Gehirn konfigurieren</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowBrainSelect(false)}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
|
||||
const isAlias = ["auto", "fast", "heavy"].includes(m)
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => changeBrainModel(m)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
|
||||
agent.brain_model === m || (!agent.brain_model && m === "auto")
|
||||
? "text-primary font-bold bg-primary/10 border-primary/30"
|
||||
: "text-foreground bg-background/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="font-semibold truncate max-w-[280px]">{m}</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
|
||||
</span>
|
||||
</div>
|
||||
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from "react"
|
||||
import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
export function AgentStatusCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: agent } = useAgentStatus(3_000)
|
||||
const { data: modelsData } = useModels()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
||||
|
||||
const models = modelsData?.models ?? []
|
||||
|
||||
async function changeBrainModel(model: string) {
|
||||
try {
|
||||
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
|
||||
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
|
||||
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
|
||||
</div>
|
||||
{agent?.terminal_url && (
|
||||
<a
|
||||
href={resolveExternalUrl(agent.terminal_url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
|
||||
agent.terminal_reachable
|
||||
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
|
||||
: "border border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> Terminal öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Terminal</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.terminal_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.terminal_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowBrainSelect(true)}
|
||||
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span>
|
||||
<Cpu className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-1 font-mono text-primary truncate flex items-center gap-1">
|
||||
<Layers className="h-3 w-3 shrink-0" />
|
||||
{agent.brain_model || "auto"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span>
|
||||
<Wrench className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono mt-1 space-y-0.5 text-muted-foreground">
|
||||
<div>Config: {agent.has_config ? <span className="text-emerald-400">✓</span> : "—"}</div>
|
||||
<div>Skills: {agent.has_skills ? <span className="text-emerald-400">✓</span> : "—"}</div>
|
||||
<div>Memory: {agent.has_memories ? <span className="text-emerald-400">✓</span> : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status…</div>
|
||||
)}
|
||||
</div>
|
||||
{agent && (
|
||||
<div className="mt-3 border-t border-border/30 pt-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>Telegram</span>
|
||||
<span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}>
|
||||
{agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>MCP-Server</span>
|
||||
<span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>PC Executor</span>
|
||||
<span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}>
|
||||
{agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent && showBrainSelect && (
|
||||
<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-md 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-1.5">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span>Hermes-Gehirn konfigurieren</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowBrainSelect(false)}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
|
||||
const isAlias = ["auto", "fast", "heavy"].includes(m)
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => changeBrainModel(m)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
|
||||
agent.brain_model === m || (!agent.brain_model && m === "auto")
|
||||
? "text-primary font-bold bg-primary/10 border-primary/30"
|
||||
: "text-foreground bg-background/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="font-semibold truncate max-w-[280px]">{m}</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
|
||||
</span>
|
||||
</div>
|
||||
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
import { useState } from "react"
|
||||
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { useQueryClient, qk } from "@/lib/queries"
|
||||
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Farb-/Text-Ton pro Check-Status.
|
||||
const DOT: Record<string, string> = {
|
||||
ok: "bg-emerald-500",
|
||||
warn: "bg-amber-500",
|
||||
down: "bg-red-500",
|
||||
loading: "bg-muted-foreground/40",
|
||||
}
|
||||
|
||||
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
|
||||
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
|
||||
const { verdict, checks, memory, problems } = useLucyHealth()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const qc = useQueryClient()
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
|
||||
async function runRepair(id: string, repair: Repair) {
|
||||
setBusy((b) => ({ ...b, [id]: true }))
|
||||
try {
|
||||
if (repair.kind === "loadModel") {
|
||||
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
|
||||
} else {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: repair.service }),
|
||||
})
|
||||
if (!r.ok) {
|
||||
showAlert(
|
||||
"Reparatur nicht ganz geklappt",
|
||||
(r.err || "Unbekannter Fehler") +
|
||||
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
|
||||
qc.invalidateQueries({ queryKey: qk.health })
|
||||
qc.invalidateQueries({ queryKey: qk.services })
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
} catch (e: any) {
|
||||
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
|
||||
const T = frame === "box"
|
||||
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
|
||||
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
|
||||
|
||||
// Banner-Optik je Gesamt-Verdikt.
|
||||
const banner = {
|
||||
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
|
||||
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
|
||||
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
|
||||
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
|
||||
}[verdict]
|
||||
const BannerIcon = banner.icon
|
||||
|
||||
const memTone = {
|
||||
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
|
||||
}[memory.status]
|
||||
const memText = {
|
||||
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
|
||||
}[memory.status]
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
|
||||
{/* Gesamt-Verdikt */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
|
||||
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
|
||||
<p className="text-xs text-muted-foreground">{banner.sub}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speicher-Ampel */}
|
||||
<div className="mt-4 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">
|
||||
<HardDrive className="h-3.5 w-3.5" /> Speicher
|
||||
</span>
|
||||
<span className={cn("font-mono font-bold", memText)}>
|
||||
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.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", memTone)} style={{ width: `${memory.pct}%` }} />
|
||||
</div>
|
||||
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
|
||||
</div>
|
||||
|
||||
{/* Fähigkeiten-Checkliste */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
{checks.map((c) => (
|
||||
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
|
||||
const Icon = c.icon
|
||||
const bad = c.status === "down" || c.status === "warn"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
|
||||
c.status === "down" ? "border-red-500/25 bg-red-500/5"
|
||||
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
|
||||
: "border-border/30 bg-background/15",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
|
||||
{c.label}
|
||||
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
|
||||
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bad && c.repair && (
|
||||
<button
|
||||
onClick={onRepair}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
|
||||
c.status === "down"
|
||||
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
|
||||
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
|
||||
)}
|
||||
title={c.repair.label}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
|
||||
{c.repair.label}
|
||||
</button>
|
||||
)}
|
||||
{bad && !c.repair && (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
|
||||
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from "react"
|
||||
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { useQueryClient, qk } from "@/lib/queries"
|
||||
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Farb-/Text-Ton pro Check-Status.
|
||||
const DOT: Record<string, string> = {
|
||||
ok: "bg-emerald-500",
|
||||
warn: "bg-amber-500",
|
||||
down: "bg-red-500",
|
||||
loading: "bg-muted-foreground/40",
|
||||
}
|
||||
|
||||
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
|
||||
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
|
||||
const { verdict, checks, memory, problems } = useLucyHealth()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const qc = useQueryClient()
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
|
||||
async function runRepair(id: string, repair: Repair) {
|
||||
setBusy((b) => ({ ...b, [id]: true }))
|
||||
try {
|
||||
if (repair.kind === "loadModel") {
|
||||
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
|
||||
} else {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: repair.service }),
|
||||
})
|
||||
if (!r.ok) {
|
||||
showAlert(
|
||||
"Reparatur nicht ganz geklappt",
|
||||
(r.err || "Unbekannter Fehler") +
|
||||
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
|
||||
qc.invalidateQueries({ queryKey: qk.health })
|
||||
qc.invalidateQueries({ queryKey: qk.services })
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
} catch (e: any) {
|
||||
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
|
||||
const T = frame === "box"
|
||||
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
|
||||
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
|
||||
|
||||
// Banner-Optik je Gesamt-Verdikt.
|
||||
const banner = {
|
||||
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
|
||||
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
|
||||
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
|
||||
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
|
||||
}[verdict]
|
||||
const BannerIcon = banner.icon
|
||||
|
||||
const memTone = {
|
||||
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
|
||||
}[memory.status]
|
||||
const memText = {
|
||||
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
|
||||
}[memory.status]
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
|
||||
{/* Gesamt-Verdikt */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
|
||||
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
|
||||
<p className="text-xs text-muted-foreground">{banner.sub}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speicher-Ampel */}
|
||||
<div className="mt-4 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">
|
||||
<HardDrive className="h-3.5 w-3.5" /> Speicher
|
||||
</span>
|
||||
<span className={cn("font-mono font-bold", memText)}>
|
||||
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.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", memTone)} style={{ width: `${memory.pct}%` }} />
|
||||
</div>
|
||||
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
|
||||
</div>
|
||||
|
||||
{/* Fähigkeiten-Checkliste */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
{checks.map((c) => (
|
||||
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
|
||||
const Icon = c.icon
|
||||
const bad = c.status === "down" || c.status === "warn"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
|
||||
c.status === "down" ? "border-red-500/25 bg-red-500/5"
|
||||
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
|
||||
: "border-border/30 bg-background/15",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
|
||||
{c.label}
|
||||
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
|
||||
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bad && c.repair && (
|
||||
<button
|
||||
onClick={onRepair}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
|
||||
c.status === "down"
|
||||
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
|
||||
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
|
||||
)}
|
||||
title={c.repair.label}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
|
||||
{c.repair.label}
|
||||
</button>
|
||||
)}
|
||||
{bad && !c.repair && (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
|
||||
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
import { useState } from "react"
|
||||
import { Brain, Plus } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useMemory, useQueryClient } from "@/lib/queries"
|
||||
|
||||
export function MemoryInputCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: memories = [] } = useMemory({ limit: 3 })
|
||||
const [memContent, setMemContent] = useState("")
|
||||
const [memCat, setMemCat] = useState("stable")
|
||||
const [savingMem, setSavingMem] = useState(false)
|
||||
|
||||
async function saveQuickMemory() {
|
||||
if (!memContent.trim() || savingMem) return
|
||||
setSavingMem(true)
|
||||
try {
|
||||
await api("/api/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
|
||||
})
|
||||
setMemContent("")
|
||||
qc.invalidateQueries({ queryKey: ["memory"] })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setSavingMem(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={memContent}
|
||||
onChange={(e) => setMemContent(e.target.value)}
|
||||
aria-label="Fakt oder Regel im Gedächtnis speichern"
|
||||
placeholder="Fakt / Regel im Pool speichern…"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<select
|
||||
value={memCat}
|
||||
onChange={(e) => setMemCat(e.target.value)}
|
||||
aria-label="Kategorie"
|
||||
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
|
||||
>
|
||||
<option value="stable">🔵 Fakt</option>
|
||||
<option value="instruction">📋 Regel</option>
|
||||
<option value="user">👤 User</option>
|
||||
<option value="versioned">🟡 Version</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={saveQuickMemory}
|
||||
disabled={!memContent.trim() || savingMem}
|
||||
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 space-y-1.5">
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
|
||||
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
|
||||
{memories.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
|
||||
) : (
|
||||
memories.map((m) => (
|
||||
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
|
||||
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
|
||||
{m.category}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
|
||||
{m.content}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Steht allen Clients per MCP zur Verfügung.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from "react"
|
||||
import { Brain, Plus } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useMemory, useQueryClient } from "@/lib/queries"
|
||||
|
||||
export function MemoryInputCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: memories = [] } = useMemory({ limit: 3 })
|
||||
const [memContent, setMemContent] = useState("")
|
||||
const [memCat, setMemCat] = useState("stable")
|
||||
const [savingMem, setSavingMem] = useState(false)
|
||||
|
||||
async function saveQuickMemory() {
|
||||
if (!memContent.trim() || savingMem) return
|
||||
setSavingMem(true)
|
||||
try {
|
||||
await api("/api/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
|
||||
})
|
||||
setMemContent("")
|
||||
qc.invalidateQueries({ queryKey: ["memory"] })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setSavingMem(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={memContent}
|
||||
onChange={(e) => setMemContent(e.target.value)}
|
||||
aria-label="Fakt oder Regel im Gedächtnis speichern"
|
||||
placeholder="Fakt / Regel im Pool speichern…"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<select
|
||||
value={memCat}
|
||||
onChange={(e) => setMemCat(e.target.value)}
|
||||
aria-label="Kategorie"
|
||||
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
|
||||
>
|
||||
<option value="stable">🔵 Fakt</option>
|
||||
<option value="instruction">📋 Regel</option>
|
||||
<option value="user">👤 User</option>
|
||||
<option value="versioned">🟡 Version</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={saveQuickMemory}
|
||||
disabled={!memContent.trim() || savingMem}
|
||||
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 space-y-1.5">
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
|
||||
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
|
||||
{memories.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
|
||||
) : (
|
||||
memories.map((m) => (
|
||||
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
|
||||
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
|
||||
{m.category}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
|
||||
{m.content}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Steht allen Clients per MCP zur Verfügung.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { useState } from "react"
|
||||
import { ExternalLink, RefreshCw, Server } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useServices } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
export function ServicesCard() {
|
||||
const { data: svc } = useServices(3_000)
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [restarting, setRestarting] = useState<Record<string, boolean>>({})
|
||||
|
||||
async function restart(id: string) {
|
||||
setRestarting((p) => ({ ...p, [id]: true }))
|
||||
try {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST", body: JSON.stringify({ service: id }),
|
||||
})
|
||||
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler: ${e.message}`)
|
||||
} finally {
|
||||
setRestarting((p) => ({ ...p, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
|
||||
>
|
||||
Logs / Pflege
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{svc ? (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="space-y-1.5">
|
||||
{svc.services.map((x) => (
|
||||
<div key={x.unit} className="group flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-black/40", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-foreground">{x.name}</div>
|
||||
<div className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => restart(x.unit)} disabled={restarting[x.unit]}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-primary/5 hover:text-primary group-hover:opacity-100"
|
||||
title="Dienst neu starten"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-3 border-t border-border/20 pt-2.5 text-[10px] font-semibold text-muted-foreground">
|
||||
<a href={resolveExternalUrl(svc.links.engine_ui)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
|
||||
<ExternalLink className="h-3 w-3" /> Engine
|
||||
</a>
|
||||
<a href={resolveExternalUrl(svc.links.gateway)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
|
||||
<ExternalLink className="h-3 w-3" /> Gateway
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste…</div>
|
||||
)}
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from "react"
|
||||
import { ExternalLink, RefreshCw, Server } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useServices } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
export function ServicesCard() {
|
||||
const { data: svc } = useServices(3_000)
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [restarting, setRestarting] = useState<Record<string, boolean>>({})
|
||||
|
||||
async function restart(id: string) {
|
||||
setRestarting((p) => ({ ...p, [id]: true }))
|
||||
try {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST", body: JSON.stringify({ service: id }),
|
||||
})
|
||||
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler: ${e.message}`)
|
||||
} finally {
|
||||
setRestarting((p) => ({ ...p, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
|
||||
>
|
||||
Logs / Pflege
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{svc ? (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="space-y-1.5">
|
||||
{svc.services.map((x) => (
|
||||
<div key={x.unit} className="group flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-black/40", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-foreground">{x.name}</div>
|
||||
<div className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => restart(x.unit)} disabled={restarting[x.unit]}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-primary/5 hover:text-primary group-hover:opacity-100"
|
||||
title="Dienst neu starten"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-3 border-t border-border/20 pt-2.5 text-[10px] font-semibold text-muted-foreground">
|
||||
<a href={resolveExternalUrl(svc.links.engine_ui)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
|
||||
<ExternalLink className="h-3 w-3" /> Engine
|
||||
</a>
|
||||
<a href={resolveExternalUrl(svc.links.gateway)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
|
||||
<ExternalLink className="h-3 w-3" /> Gateway
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste…</div>
|
||||
)}
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
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(2_000)
|
||||
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 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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(2_000)
|
||||
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 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
|
||||
<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,60 +1,60 @@
|
||||
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, ModelsCard, …) 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" }
|
||||
}
|
||||
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, ModelsCard, …) 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,203 +1,203 @@
|
||||
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(4_000)
|
||||
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="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
|
||||
<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="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 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>
|
||||
)
|
||||
}
|
||||
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(4_000)
|
||||
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="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
|
||||
<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="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 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,123 +1,123 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Key, Shield } from "lucide-react"
|
||||
|
||||
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
|
||||
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
|
||||
// `showAlert` (gemeinsamer Dialog).
|
||||
export function SettingsTab({ open, showAlert }: {
|
||||
open: boolean
|
||||
showAlert: (title: string, message: string) => void
|
||||
}) {
|
||||
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
|
||||
const [hfTokenInput, setHfTokenInput] = useState("")
|
||||
const [showSudo, setShowSudo] = useState(false)
|
||||
const [showHf, setShowHf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
|
||||
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo Passwort */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="h-4 w-4 text-amber-400" />
|
||||
Host Sudo-Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSudo ? "text" : "password"}
|
||||
value={sudoPasswordInput}
|
||||
onChange={(e) => setSudoPasswordInput(e.target.value)}
|
||||
aria-label="Host Sudo-Passwort"
|
||||
name="sudo-password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Sudo-Passwort für System-Operationen"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSudo(!showSudo)}
|
||||
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace Token */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Key className="h-4 w-4 text-violet-400" />
|
||||
HuggingFace API Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showHf ? "text" : "password"}
|
||||
value={hfTokenInput}
|
||||
onChange={(e) => setHfTokenInput(e.target.value)}
|
||||
aria-label="HuggingFace API Token"
|
||||
name="hf-token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="hf_…"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHf(!showHf)}
|
||||
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
|
||||
localStorage.setItem("mc_hf_token", hfTokenInput);
|
||||
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
|
||||
}}
|
||||
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoPasswordInput("");
|
||||
setHfTokenInput("");
|
||||
localStorage.removeItem("mc_sudo_password");
|
||||
localStorage.removeItem("mc_hf_token");
|
||||
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
|
||||
}}
|
||||
className="h-9 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"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Key, Shield } from "lucide-react"
|
||||
|
||||
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
|
||||
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
|
||||
// `showAlert` (gemeinsamer Dialog).
|
||||
export function SettingsTab({ open, showAlert }: {
|
||||
open: boolean
|
||||
showAlert: (title: string, message: string) => void
|
||||
}) {
|
||||
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
|
||||
const [hfTokenInput, setHfTokenInput] = useState("")
|
||||
const [showSudo, setShowSudo] = useState(false)
|
||||
const [showHf, setShowHf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
|
||||
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo Passwort */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="h-4 w-4 text-amber-400" />
|
||||
Host Sudo-Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSudo ? "text" : "password"}
|
||||
value={sudoPasswordInput}
|
||||
onChange={(e) => setSudoPasswordInput(e.target.value)}
|
||||
aria-label="Host Sudo-Passwort"
|
||||
name="sudo-password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Sudo-Passwort für System-Operationen"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSudo(!showSudo)}
|
||||
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace Token */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Key className="h-4 w-4 text-violet-400" />
|
||||
HuggingFace API Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showHf ? "text" : "password"}
|
||||
value={hfTokenInput}
|
||||
onChange={(e) => setHfTokenInput(e.target.value)}
|
||||
aria-label="HuggingFace API Token"
|
||||
name="hf-token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="hf_…"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHf(!showHf)}
|
||||
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
|
||||
localStorage.setItem("mc_hf_token", hfTokenInput);
|
||||
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
|
||||
}}
|
||||
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoPasswordInput("");
|
||||
setHfTokenInput("");
|
||||
localStorage.removeItem("mc_sudo_password");
|
||||
localStorage.removeItem("mc_hf_token");
|
||||
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
|
||||
}}
|
||||
className="h-9 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"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,196 +1,196 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
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).
|
||||
|
||||
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||
export const SERVICES = [
|
||||
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
|
||||
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
|
||||
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
|
||||
{ id: "voice-service", label: "Voice (Sprache)", type: "user", reach: "voice" },
|
||||
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
|
||||
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatBytes(bytes?: number) {
|
||||
if (bytes == null) return ""
|
||||
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
}
|
||||
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).
|
||||
|
||||
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||
export const SERVICES = [
|
||||
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
|
||||
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
|
||||
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
|
||||
{ id: "voice-service", label: "Voice (Sprache)", type: "user", reach: "voice" },
|
||||
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
|
||||
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatBytes(bytes?: number) {
|
||||
if (bytes == null) return ""
|
||||
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user