Feat: Add Hermes brain change buttons and skills.sh guide source

This commit is contained in:
Hitonabi
2026-06-26 13:34:53 +02:00
parent 0c7b0b19af
commit 066feee3ea
16 changed files with 1201 additions and 504 deletions
+12 -1
View File
@@ -1,12 +1,23 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter
from pydantic import BaseModel
from services.agent import agent_status
from services.agent import agent_status, update_brain_model
router = APIRouter(prefix="/api")
class BrainReq(BaseModel):
model: str
@router.get("/agent/status")
def status() -> dict:
return agent_status()
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
+44
View File
@@ -46,3 +46,47 @@ def agent_status() -> dict:
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
}
def update_brain_model(new_model: str) -> bool:
from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {}
cfg["model"]["model"] = new_model
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
pass
return True
except Exception:
return False
+14 -3
View File
@@ -70,9 +70,20 @@ def model_upgrades() -> list[dict]:
cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values())
out = []
ROLE_MAP = {
"reasoning": "heavy",
"agent": "fast",
"scout": "fast",
"coder": "coder",
"vision": "vision"
}
for c in disc.get("categories", []):
role = c["role"]
if role not in active_roles:
disc_role = c["role"]
mapped_role = ROLE_MAP.get(disc_role, disc_role)
if mapped_role not in active_roles:
continue
rec = c.get("recommended")
@@ -82,7 +93,7 @@ def model_upgrades() -> list[dict]:
stem = base[:-5] if base.endswith("-gguf") else base
if base in cmds or (stem and stem in cmds):
continue
out.append({"role": role, "title": c["title"], "repo": rec})
out.append({"role": mapped_role, "title": c["title"], "repo": rec})
return out
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-oiIMDlga.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WcZJ9RJs.css">
<script type="module" crossorigin src="/assets/index-rbtAsh2N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJE5r1SD.css">
</head>
<body>
<div id="root"></div>
+67
View File
@@ -0,0 +1,67 @@
import { X } from "lucide-react"
export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function CustomDialog({ type, title, message, defaultValue, onConfirm, onCancel }: CustomDialogProps) {
return (
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<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())}
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">{message}</p>
{type === "prompt" && (
<input
type="text"
id="custom-dialog-input"
defaultValue={defaultValue}
className="w-full 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") {
const val = (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
onConfirm(val)
}
}}
/>
)}
<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"
? (document.getElementById("custom-dialog-input") as HTMLInputElement)?.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>
)
}
+94 -16
View File
@@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
interface SystemDrawerProps {
open: boolean
@@ -34,6 +36,51 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance")
const [checkingUpdates, setCheckingUpdates] = useState(false)
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
function formatLastCheck(ts?: number | null) {
if (!ts) return "Nie"
return new Date(ts * 1000).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})
}
// Credentials State
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
@@ -117,7 +164,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler beim Starten des OS-Updates: ${e.message}`)
showAlert("Fehler", `Fehler beim Starten des OS-Updates: ${e.message}`)
}
}
@@ -127,7 +174,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler beim Engine-Update: ${e.message}`)
}
}
@@ -138,7 +185,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler bei der Update-Suche: ${e.message}`)
showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`)
} finally {
setCheckingUpdates(false)
}
@@ -150,24 +197,30 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
method: "POST",
body: JSON.stringify({ repo, role })
})
alert(`Modell-Upgrade für '${role}' (${repo}) gestartet.`)
showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`)
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler beim Starten des Modell-Upgrades: ${e.message}`)
showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`)
}
}
async function triggerReboot() {
if (!confirm("Bist du sicher, dass du das gesamte Host-System neu starten willst?")) return
showConfirm(
"Reboot bestätigen",
"Bist du sicher, dass du das gesamte Host-System neu starten willst?",
async () => {
try {
await api("/api/maintenance/reboot", { method: "POST" })
alert("Reboot ausgelöst. System startet neu...")
showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => {
onClose()
})
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler beim Reboot: ${e.message}`)
}
}
)
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
@@ -177,15 +230,16 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
body: JSON.stringify({ service: serviceId })
})
if (res.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => {
if (activeTab === "logs" && selectedService === serviceId) {
loadServiceLogs(serviceId)
}
})
} else {
alert(`Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler beim Neustart: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
@@ -196,7 +250,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
loadJobs()
} catch (e: any) {
alert(`Fehler beim Abbrechen: ${e.message}`)
showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`)
}
}
@@ -277,6 +331,12 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3>
<div className="flex items-center gap-2">
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
<button
onClick={triggerCheckUpdates}
disabled={checkingUpdates}
@@ -286,6 +346,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
Nach Updates suchen
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
@@ -326,7 +387,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
{/* Modell Upgrades */}
{updates?.model_list && updates.model_list.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Upgrades</h3>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
</div>
<div className="space-y-2">
{updates.model_list.map((m) => (
<div key={m.role} className="p-3 rounded-xl border border-border/60 bg-background/20 flex flex-col gap-2">
@@ -588,9 +656,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
alert("Einstellungen erfolgreich lokal gespeichert.");
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"
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>
@@ -600,9 +668,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
alert("Zugangsdaten gelöscht.");
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"
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>
@@ -611,6 +679,16 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
)}
</div>
</div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</>
)
}
+134 -7
View File
@@ -1,14 +1,20 @@
import { useEffect, useState, useRef, useCallback } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield } from "lucide-react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield, Check, X } from "lucide-react"
import { api, type AgentStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; detail?: string; icon: any }) {
function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: boolean; detail?: string; icon: any; onClick?: () => void }) {
return (
<div className={cn(
<div
onClick={onClick}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/40 transition-all duration-300",
ok ? "border-border/60" : "border-amber-500/30"
)}>
ok ? "border-border/60" : "border-amber-500/30",
onClick && "cursor-pointer hover:bg-card/70"
)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<Icon className={cn("h-4.5 w-4.5", ok ? "text-primary" : "text-amber-500")} />
@@ -26,6 +32,18 @@ function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; d
</div>
{detail && <div className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]" title={detail}>{detail}</div>}
</div>
{onClick && (
<button
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className="mt-2 flex items-center justify-center gap-1.5 w-full py-1.5 px-3 rounded-lg border border-primary/30 bg-primary/10 hover:bg-primary/20 text-primary text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer font-space"
>
<Cpu className="h-3.5 w-3.5" />
<span>Gehirn wechseln</span>
</button>
)}
</div>
)
}
@@ -36,6 +54,21 @@ export function AgentView() {
// Graph UI state
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false)
const [availableModels, setAvailableModels] = useState<string[]>([])
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({ type: "alert", title, message, onConfirm })
}
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
@@ -70,8 +103,32 @@ export function AgentView() {
api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
}
function loadModels() {
api<{ models: { name: string }[] }>("/api/models")
.then((res) => {
const names = res.models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
setAvailableModels(["auto", "fast", "heavy", ...names])
})
.catch((e) => console.error("Error loading models", e))
}
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.`)
loadData()
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
useEffect(() => {
loadData()
loadModels()
const t = setInterval(loadData, 5000)
return () => clearInterval(t)
}, [])
@@ -147,6 +204,7 @@ export function AgentView() {
ok={s.gateway_reachable}
detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"}
icon={Cpu}
onClick={() => setShowBrainSelect(true)}
/>
<Tile
label="Verdrahtung"
@@ -236,12 +294,13 @@ export function AgentView() {
{/* COLUMN 3: Brain & Wiring */}
<div
className={cn(
"absolute z-10 w-44 py-1.5 px-3 -translate-y-1/2 -translate-x-1/2 rounded-xl border flex flex-col justify-center hover:border-primary/50 transition-all text-left shadow select-none",
"absolute z-20 w-44 py-1.5 px-3 -translate-y-1/2 -translate-x-1/2 rounded-xl border flex flex-col justify-center hover:border-primary/50 transition-all text-left shadow select-none cursor-pointer",
s.gateway_reachable ? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5" : "border-border/60 bg-card/75"
)}
style={{ left: "85%", top: "30%" }}
onMouseEnter={() => setHoveredNode("brain")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setShowBrainSelect(true)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-muted-foreground">
@@ -250,8 +309,13 @@ export function AgentView() {
</div>
{s.gateway_reachable && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
</div>
<div className="text-[10px] font-mono font-medium truncate mt-0.5 text-foreground font-space" title={s.brain_model}>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-mono font-medium truncate text-foreground font-space max-w-[100px]" title={s.brain_model}>
{s.brain_model || "auto"}
</span>
<span className="text-[8px] bg-primary/20 hover:bg-primary/30 border border-primary/30 text-primary px-1.5 py-0.5 rounded uppercase font-bold tracking-wider transition-colors">
Ändern
</span>
</div>
</div>
@@ -358,6 +422,69 @@ export function AgentView() {
)}
</div>
)}
{/* Brain Selection Modal */}
{s && 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">
{availableModels.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",
s.brain_model === m || (!s.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>
{(s.brain_model === m || (!s.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
);
})}
</div>
</div>
</div>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={() => dialog.onConfirm && dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+127 -4
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw, Check } from "lucide-react"
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
@@ -55,6 +57,55 @@ export function DashboardView() {
error?: string
}>({ open: false, actionPath: "", actionLabel: "" })
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false)
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", {
method: "POST",
body: JSON.stringify({ model })
})
setDialog({
type: "alert",
title: "Erfolgreich",
message: `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`,
onConfirm: () => setDialog(null)
})
loadData()
setShowBrainSelect(false)
} catch (e: any) {
setDialog({
type: "alert",
title: "Fehler",
message: `Fehler beim Wechseln des Gehirns: ${e.message}`,
onConfirm: () => setDialog(null)
})
}
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
// Quick Memory Form State
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
@@ -385,7 +436,7 @@ export function DashboardView() {
</div>
<button
onClick={() => { if (confirm("Bist du sicher, dass du das Host-System neu starten willst?")) postAction("/api/maintenance/reboot", "Reboot") }}
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
disabled={loading}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
@@ -490,8 +541,18 @@ export function DashboardView() {
</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">Aktives Gehirn</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 group"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</span>
<button
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-primary border border-primary/20 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-all cursor-pointer font-space"
>
<Cpu className="h-3 w-3" /> Ändern
</button>
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
@@ -628,6 +689,68 @@ export function DashboardView() {
</div>
</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>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={() => dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+36 -5
View File
@@ -388,8 +388,19 @@ export function GuideView() {
<p>
<strong>Warum in MC2?</strong> MCP trennt den AI-Kern von der Umgebung. Statt für jeden Editor eigene Tools zu schreiben, binden deine Agenten (Roo Code, Hermes) einfach MCP-Server an. Diese Server können Dateien lesen, Websuchen durchführen, Git bedienen oder mit deiner App interagieren.
</p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[10px] text-foreground">
<span className="text-violet-400">Prinzip:</span> Agent MCP Gateway Lokale Tools (Dateien, Terminal, Web)
<div className="space-y-1.5 p-3 bg-background/25 rounded-xl border border-border/30 text-[10px]">
<div className="font-bold text-foreground">Gute Quellen für MCP Server:</div>
<ul className="list-disc pl-4 space-y-1 text-muted-foreground">
<li>
<a href="https://smithery.ai/" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">Smithery Registry</a> Ein Portal zum Suchen und automatischen Installieren von MCP Servern.
</li>
<li>
<a href="https://glama.ai/mcp/servers" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">Glama MCP Registry</a> Eine kuratierte, umfangreiche Community-Datenbank von MCP Servern.
</li>
<li>
<a href="https://github.com/modelcontextprotocol/servers" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">Offizielles Anthropic Repo</a> Das offizielle Repository mit Standards wie filesystem, postgres, sqlite, brave-search und puppeteer.
</li>
</ul>
</div>
</div>
</div>
@@ -418,9 +429,29 @@ description: Drive development with strict TDD practices
# Instructions
...`}
</pre>
<p>
Der Agent scannt diese Ordner automatisch und erweitert seine Systembefehle bei passenden Workflows. Gute Quellen sind dein lokales Mission Control Repository oder GitHub.
</p>
<div className="space-y-1.5 p-3 bg-background/25 rounded-xl border border-border/30 text-[10px]">
<div className="font-bold text-foreground">Wo gibt es Skills &amp; wo liegen sie?</div>
<ul className="list-disc pl-4 space-y-2.5 text-muted-foreground">
<li>
<strong>skills.sh Registry &amp; CLI:</strong> Das offizielle offene Portal für Agent-Skills (<a href="https://skills.sh/" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">skills.sh</a>). Du kannst Skills direkt über das Terminal suchen und in deinem Projekt installieren:
<div className="mt-1 font-mono text-[9px] bg-background/40 p-2 rounded border border-border/30 text-cyan-300">
# Nach Skills suchen:<br />
<span className="text-foreground">npx skills find</span><br />
# Skill zum aktuellen Projekt hinzufügen:<br />
<span className="text-foreground">npx skills add [owner/repo]</span>
</div>
</li>
<li>
<strong>Globaler Pfad:</strong> <code className="text-foreground select-all">C:\Users\TobisPC\.gemini\config\plugins\agent-skills\skills\</code>. Hier sind deine vorinstallierten, global verfügbaren Skills (wie <i>code-simplification</i>, <i>api-and-interface-design</i>, etc.) abgelegt.
</li>
<li>
<strong>Projekt-Pfad:</strong> <code className="text-foreground select-all">.agents/skills/</code>. Lege diesen Ordner im Root eines beliebigen Projekts an. Dein lokaler Editor-Agent (z.B. Roo Code) liest ihn beim Starten automatisch ein.
</li>
<li>
<strong>Vorlagen / Beispiele:</strong> Kopiere einfach bestehende Skills aus dem globalen Verzeichnis oder erstelle deine eigenen, indem du eine <code>SKILL.md</code> mit YAML-Header (name, description) anlegst.
</li>
</ul>
</div>
</div>
</div>
+59 -4
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react"
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
@@ -32,6 +34,41 @@ export function MemoryView() {
const [error, setError] = useState("")
const [deduping, setDeduping] = useState(false)
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
function load() {
const params = new URLSearchParams()
if (q) params.set("q", q)
@@ -66,18 +103,26 @@ export function MemoryView() {
body: JSON.stringify({ apply: false }),
})
if (dry.duplicate_count === 0) {
alert("Keine Dubletten gefunden — alles sauber.")
showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber.")
return
}
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) {
showConfirm(
"Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => {
try {
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
})
load()
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
}
}
)
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally {
setDeduping(false)
}
@@ -239,6 +284,16 @@ export function MemoryView() {
})
)}
</div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+165 -46
View File
@@ -3,6 +3,8 @@ import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X,
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo, type RoutingResp, type UpdatesResp, type ConnectResp } from "@/lib/api"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function fmtBytes(b?: number) {
if (!b) return ""
@@ -16,8 +18,9 @@ function fmtEta(s?: number) {
return m > 0 ? `${m} min` : `${s} s`
}
function JobsBar() {
function JobsBar({ onError }: { onError?: (msg: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([])
const [errorMsg, setErrorMsg] = useState<string | null>(null)
function load() {
api<{ jobs: Job[] }>("/api/jobs")
@@ -36,7 +39,8 @@ function JobsBar() {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler: ${e.message}`)
if (onError) onError(e.message)
else setErrorMsg(e.message)
}
}
@@ -83,11 +87,20 @@ function JobsBar() {
</span>
</div>
))}
{errorMsg && (
<CustomDialog
type="alert"
title="Fehler"
message={errorMsg}
onConfirm={() => setErrorMsg(null)}
/>
)}
</div>
)
}
function fmtSize(b: number | null) {
function fmtSize(b?: number | null) {
if (!b) return "—"
const gb = b / 1024 ** 3
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
@@ -135,12 +148,68 @@ function Cockpit() {
// UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleDrop, setActiveRoleDrop] = useState<string | null>(null)
const [activeRoleForAssign, setActiveRoleForAssign] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
const [filterMode, setFilterMode] = useState<"all" | "in_use">("all")
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
onConfirm: (val?: string) => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function showConfirm(title: string, message: string, onConfirm: () => void, onCancel?: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => {
setDialog(null)
if (onCancel) onCancel()
}
})
}
function showPrompt(title: string, message: string, defaultValue: string, onConfirm: (val?: string) => void, onCancel?: () => void) {
setDialog({
type: "prompt",
title,
message,
defaultValue,
onConfirm: (val) => {
setDialog(null)
onConfirm(val)
},
onCancel: () => {
setDialog(null)
if (onCancel) onCancel()
}
})
}
const filteredModels = models.filter((m) => {
if (filterMode === "in_use") {
return !!m.role || running.includes(m.name)
@@ -226,7 +295,7 @@ function Cockpit() {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Laden des Modells: ${e.message}`)
showAlert("Fehler", `Fehler beim Laden des Modells: ${e.message}`)
}
}
@@ -235,7 +304,7 @@ function Cockpit() {
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Entladen des Modells: ${e.message}`)
showAlert("Fehler", `Fehler beim Entladen des Modells: ${e.message}`)
}
}
@@ -244,25 +313,28 @@ function Cockpit() {
await api("/api/models/unload", { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Entladen aller Modelle: ${e.message}`)
showAlert("Fehler", `Fehler beim Entladen aller Modelle: ${e.message}`)
}
}
async function handleRoleChange(role: string, modelName: string) {
setActiveRoleDrop(null)
try {
await api(`/api/models/${encodeURIComponent(modelName)}/role`, {
method: "POST",
body: JSON.stringify({ role: role || null }),
})
load()
} catch (e) {
alert(`Fehler beim Zuweisen der Rolle: ${e}`)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Zuweisen der Rolle: ${e.message || e}`)
}
}
async function handleSetCtx(name: string, cur: number | null) {
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
showPrompt(
"Kontextlänge anpassen",
"Gib die gewünschte Kontextlänge in Tokens an:",
String(cur || 32768),
async (v) => {
if (!v) return
try {
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
@@ -270,20 +342,27 @@ function Cockpit() {
body: JSON.stringify({ ctx: parseInt(v, 10) }),
})
load()
} catch (e) {
alert(`Fehler beim Setzen des Kontexts: ${e}`)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Setzen des Kontexts: ${e.message || e}`)
}
}
)
}
async function handleDelete(name: string) {
if (!confirm(`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`)) return
showConfirm(
"Modell löschen?",
`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`,
async () => {
try {
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
load()
} catch (e) {
alert(`Fehler beim Löschen: ${e}`)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Löschen: ${e.message || e}`)
}
}
)
}
async function handleSmartUpgrade(repo: string, role: string, quant: string, toolCapable: boolean) {
try {
@@ -291,9 +370,9 @@ function Cockpit() {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
alert(`Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`)
} catch (e) {
alert(`Fehler beim Starten des Upgrades: ${e}`)
showAlert("Herunterladen gestartet", `Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Starten des Upgrades: ${e.message || e}`)
}
}
@@ -580,7 +659,7 @@ function Cockpit() {
: "border-dashed border-border/40 bg-background/20"
)}
style={{ left: "90%", top: yPositions[indexMap] }}
onClick={() => setActiveRoleDrop(activeRoleDrop === role ? null : role)}
onClick={() => setActiveRoleForAssign(role)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
@@ -589,32 +668,6 @@ function Cockpit() {
<div className="text-[10px] font-mono font-medium truncate mt-0.5 text-foreground max-w-[150px]">
{activeModel ? activeModel.name.split("/").pop()?.replace(".gguf", "") : "Keine Zuweisung"}
</div>
{/* Inline drop selection */}
{activeRoleDrop === role && (
<div className="absolute right-0 top-full mt-1 z-35 w-52 rounded-xl border border-border/80 bg-popover/95 backdrop-blur-md p-1.5 shadow-2xl space-y-1">
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 px-2 py-1 select-none">Modell zuweisen:</div>
<button
onClick={() => handleRoleChange(role, "")}
className="w-full text-left px-2.5 py-1.5 rounded-lg text-[10px] hover:bg-accent text-red-400 font-semibold cursor-pointer"
>
Zuweisung entfernen
</button>
{models.map((m) => (
<button
key={m.name}
onClick={() => handleRoleChange(role, m.name)}
className={cn(
"w-full text-left px-2.5 py-1.5 rounded-lg text-[10px] hover:bg-accent flex items-center justify-between font-mono cursor-pointer",
m.role === role ? "text-primary font-bold" : "text-foreground"
)}
>
<span className="truncate max-w-[150px]">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
{m.role === role && <Check className="h-3 w-3 shrink-0" />}
</button>
))}
</div>
)}
</div>
)
})}
@@ -1018,6 +1071,72 @@ function Cockpit() {
</div>
)}
</div>
{/* Role Assignment Modal */}
{activeRoleForAssign && (
<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">
Rolle '{activeRoleForAssign}' konfigurieren
</h3>
<button
onClick={() => setActiveRoleForAssign(null)}
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">
Wähle ein Modell aus deiner Bibliothek für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
<button
onClick={() => {
handleRoleChange(activeRoleForAssign, "")
setActiveRoleForAssign(null)
}}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
{models.map((m) => (
<button
key={m.name}
onClick={() => {
handleRoleChange(activeRoleForAssign, m.name)
setActiveRoleForAssign(null)
}}
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",
m.role === activeRoleForAssign
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="truncate max-w-[280px] font-semibold">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">{fmtSize(m.size_bytes)} · {m.quant}</span>
</div>
{m.role === activeRoleForAssign && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
))}
</div>
</div>
</div>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
defaultValue={dialog.defaultValue}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+35 -4
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react"
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
@@ -43,6 +45,27 @@ export function SystemView() {
const [backupMsg, setBackupMsg] = useState("")
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function load() {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
@@ -72,12 +95,12 @@ export function SystemView() {
body: JSON.stringify({ service: serviceId })
})
if (r.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
showAlert("Erfolgreich", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
} else {
alert(`Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
showAlert("Fehler beim Neustart", `Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
@@ -234,7 +257,15 @@ export function SystemView() {
</div>
)}
{/* Maintenance controls inside system view */}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}