feat: lower memory dedupe threshold for more aggressive cleaning
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user