2 Commits

19 changed files with 1924 additions and 704 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}
+7
View File
@@ -21,6 +21,13 @@ class RestartReq(BaseModel):
def updates() -> dict:
return maintenance.updates()
@router.post("/maintenance/check-updates")
def check_updates(body: SudoReq) -> dict:
res = maintenance.check_updates_job(body.sudo_password)
if isinstance(res, dict) and not res.get("ok", True):
return res
return res
@router.post("/maintenance/os-update")
def os_update(body: SudoReq) -> dict:
+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
+25 -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
@@ -168,6 +179,17 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
return {"ok": r["ok"], "text": r["out"] or r["err"]}
def check_updates_job(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
def on_done():
_engine_cache.update(ts=0.0, avail=False)
cmd = "sudo apt-get update"
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
return {"ok": True, "job_id": job_id}
def os_update_job(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
+15 -8
View File
@@ -132,20 +132,27 @@ def get_engine_version() -> dict:
if git_info:
return {**git_info, "type": "git"}
try:
binary = os.path.join(engine_path, "llama-server")
if not os.path.exists(binary):
binary = os.path.join(engine_path, "bin", "llama-server")
if not os.path.exists(binary):
binary = "llama-server"
candidates = [
os.path.join(engine_path, "llama-server"),
os.path.join(engine_path, "bin", "llama-server"),
"/usr/local/bin/llama-server",
"/usr/bin/llama-server",
"llama-server"
]
for binary in candidates:
if binary != "llama-server" and not os.path.exists(binary):
continue
try:
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
if res.returncode == 0:
lines = res.stdout.strip().splitlines()
output = (res.stdout or "").strip() or (res.stderr or "").strip()
if output:
lines = output.splitlines()
ver = lines[0] if lines else "unknown"
return {"version_text": ver, "type": "binary"}
except Exception:
pass
return {"type": "unknown"}
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-Dk6KzQ7B.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR6_L3dg.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>
)
}
+297 -16
View File
@@ -1,12 +1,14 @@
import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power } from "lucide-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
onClose: () => void
defaultTab?: "maintenance" | "logs"
defaultTab?: "maintenance" | "logs" | "settings"
}
const SERVICES = [
@@ -29,8 +31,68 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [selectedService, setSelectedService] = useState("llama-swap")
const [logs, setLogs] = useState("")
const [loadingLogs, setLoadingLogs] = useState(false)
const [logStatus, setLogStatus] = useState<string | null>(null)
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<"maintenance" | "logs">("maintenance")
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("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
useEffect(() => {
if (open && defaultTab) {
@@ -54,12 +116,16 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
function loadServiceLogs(service: string) {
setLoadingLogs(true)
api<{ ok: boolean; text: string; err?: string }>(`/api/maintenance/logs?service=${service}&lines=150`)
setLogStatus(null)
api<{ ok: boolean; text: string; err?: string; status?: string }>(`/api/maintenance/logs?service=${service}&lines=150`)
.then((res) => {
if (res.ok) {
setLogs(res.text)
} else {
setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`)
if (res.status === "incorrect_password" || res.status === "password_required") {
setLogStatus(res.status)
}
}
})
.catch((e) => setLogs(`Fehler: ${e.message}`))
@@ -98,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}`)
}
}
@@ -108,20 +174,53 @@ 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}`)
}
}
async function triggerCheckUpdates() {
setCheckingUpdates(true)
try {
await api("/api/maintenance/check-updates", { method: "POST" })
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`)
} finally {
setCheckingUpdates(false)
}
}
async function triggerModelUpgrade(repo: string, role: string) {
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role })
})
showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`)
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
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 }))
@@ -131,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 }))
}
@@ -150,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}`)
}
}
@@ -210,15 +310,43 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
>
System-Logs
</button>
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === "settings"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Einstellungen
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" ? (
{activeTab === "maintenance" && (
<>
{/* Quick Actions */}
<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}
className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50"
>
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} />
Nach Updates suchen
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
@@ -256,6 +384,40 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</button>
</div>
{/* 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">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold">{m.title}</div>
<div className="text-[10px] font-mono text-muted-foreground">{m.repo}</div>
<div className="text-[10px] text-primary font-semibold uppercase mt-0.5">Rolle: {m.role}</div>
</div>
<button
onClick={() => triggerModelUpgrade(m.repo, m.role)}
className="flex items-center gap-1.5 text-[10px] font-semibold text-emerald-400 hover:text-emerald-300 border border-emerald-500/20 bg-emerald-500/5 hover:bg-emerald-500/10 px-2 py-1 rounded-lg transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
Upgrade
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Active Jobs */}
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -347,7 +509,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div>
</div>
</>
) : (
)}
{activeTab === "logs" && (
// Logs View
<div className="flex flex-col h-full space-y-4">
{/* Service Select & Restart */}
@@ -397,7 +561,23 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
ref={logContainerRef}
className="flex-1 p-4 overflow-y-auto text-[10px] font-mono text-cyan-300/90 whitespace-pre-wrap select-text leading-relaxed scrollbar-thin"
>
{loadingLogs && !logs ? (
{logStatus === "password_required" || logStatus === "incorrect_password" ? (
<div className="flex flex-col items-center justify-center p-6 text-center h-full space-y-3">
<AlertTriangle className="h-8 w-8 text-amber-400 animate-pulse animate-duration-1000" />
<div className="text-xs font-semibold text-amber-300">
{logStatus === "incorrect_password" ? "Falsches Sudo-Passwort hinterlegt." : "Sudo-Passwort für systemd-Dienste erforderlich."}
</div>
<p className="text-[10px] text-muted-foreground max-w-xs leading-normal">
Für das Auslesen der systemd-Logs von {selectedService} werden Root-Rechte benötigt. Hinterlege dein Passwort in den Einstellungen.
</p>
<button
onClick={() => setActiveTab("settings")}
className="px-3 py-1.5 text-[10px] font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-md transition-colors mt-2"
>
Sudo-Passwort eintragen
</button>
</div>
) : loadingLogs && !logs ? (
<span className="text-muted-foreground">Lade Logs...</span>
) : (
logs || <span className="text-muted-foreground">Keine Logeinträge vorhanden.</span>
@@ -406,8 +586,109 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div>
</div>
)}
{activeTab === "settings" && (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
placeholder="hf_..."
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)}
</div>
</div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</>
)
}
+45 -1
View File
@@ -1,9 +1,53 @@
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const headers = {
"Content-Type": "application/json",
...init?.headers,
} as Record<string, string>
const sudoPassword = localStorage.getItem("mc_sudo_password")
const hfToken = localStorage.getItem("mc_hf_token")
if (sudoPassword) {
headers["X-Sudo-Password"] = sudoPassword
}
let body = init?.body
const method = init?.method?.toUpperCase() || "GET"
if (method === "POST") {
if (typeof body === "string") {
try {
const data = JSON.parse(body)
let changed = false
if (sudoPassword && !("sudo_password" in data)) {
data["sudo_password"] = sudoPassword
changed = true
}
if (hfToken && !("hf_token" in data)) {
data["hf_token"] = hfToken
changed = true
}
if (changed) {
body = JSON.stringify(data)
}
} catch (e) {
// ignore JSON parse errors
}
} else if (!body) {
const data: Record<string, any> = {}
if (sudoPassword) data["sudo_password"] = sudoPassword
if (hfToken) data["hf_token"] = hfToken
if (Object.keys(data).length > 0) {
body = JSON.stringify(data)
}
}
}
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...init,
headers,
body,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T>
+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>
)
}
+259 -2
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from "react"
import { BookOpen, Layers, Brain, Terminal, Code, RefreshCw, Cpu, Star } from "lucide-react"
import { BookOpen, Layers, Brain, Terminal, Code, RefreshCw, Cpu, Star, Compass, Wrench, Shield, ArrowRight } from "lucide-react"
import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils"
export function GuideView() {
const [mainTab, setMainTab] = useState<"connect" | "concepts">("connect")
const [activeTab, setActiveTab] = useState<"roocode" | "cursor" | "opencode">("roocode")
const [health, setHealth] = useState<Health | null>(null)
@@ -42,6 +43,34 @@ export function GuideView() {
</p>
</div>
{/* Main Mode Tabs */}
<div className="flex gap-4 border-b border-border/40 pb-px">
<button
onClick={() => setMainTab("connect")}
className={cn(
"pb-3 text-xs font-bold uppercase tracking-wider border-b-2 px-1 transition-all",
mainTab === "connect"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Editor-Anbindung
</button>
<button
onClick={() => setMainTab("concepts")}
className={cn(
"pb-3 text-xs font-bold uppercase tracking-wider border-b-2 px-1 transition-all",
mainTab === "concepts"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
KI-Wissensdatenbank (Juni 2026)
</button>
</div>
{mainTab === "connect" ? (
<>
{/* Connection HUD Panel */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div className="flex items-center gap-3">
@@ -299,10 +328,238 @@ export function GuideView() {
<ul className="text-[11px] text-muted-foreground space-y-2 list-disc pl-4 leading-relaxed">
<li><strong>Keine Verbindung?</strong> Überprüfe im Live-Verbindungsprüfer oben, ob die Box-IP erreichbar ist. Stelle sicher, dass dein lokaler PC im selben WLAN/LAN-Netzwerk wie die Box eingeloggt ist.</li>
<li><strong>Modell antwortet nicht?</strong> Schaue unter <strong>Diagnose</strong>, ob der Dienst `llama-swap` aktiv (grün) ist. Wenn nicht, klicke daneben auf <strong>Restart</strong>.</li>
<li><strong>Modell antwortet nicht?</strong> Schaue unter <strong>Diagnose</strong>, ob der Dienst `llama-swap` aktiv (grün) is. Wenn nicht, klicke daneben auf <strong>Restart</strong>.</li>
<li><strong>Hermes Agent reagiert merkwürdig?</strong> Starte in der Hermes WebUI einfach eine frische Session (neuen Chat). Ältere Chats sammeln Kontext-Müll an.</li>
</ul>
</div>
</>
) : (
/* KI-Wissensdatenbank Concepts Tab */
<div className="space-y-6">
{/* Introduction */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex items-start gap-4">
<Compass className="h-8 w-8 text-primary shrink-0 mt-0.5" />
<div className="space-y-1">
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">Entwickler-Guide: Modernes Agentic Coding (2026)</h3>
<p className="text-xs text-muted-foreground leading-normal">
Willkommen im Wissenszentrum für dein Mission Control 2 Setup. Hier erfährst du, wie die verschiedenen Technologien (MoE, MCP, Skills, Hermes) zusammenarbeiten und wie du das Maximum aus deinen AI-Prozessabläufen herausholst.
</p>
</div>
</div>
{/* Concepts Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* MoE Card */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-6 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-center gap-2 border-b border-border/20 pb-3">
<Layers className="h-5 w-5 text-cyan-400" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">1. Mixture of Experts (MoE)</h3>
<span className="text-[9px] text-cyan-400 font-mono">Effizienz durch Spezialisierung</span>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-2 leading-normal">
<p>
<strong>Was ist das?</strong> Bei traditionellen LLMs wird für jedes Wort das gesamte neuronale Netz aktiviert. Bei MoE besteht das Modell aus mehreren spezialisierten Teilnetzwerken (den <em>Experts</em>). Ein intelligenter <em>Router</em> entscheidet pro Token (Wortteil), welche Experten zur Berechnung herangezogen werden.
</p>
<p>
<strong>Warum in MC2?</strong> So können extrem leistungsstarke Modelle (wie DeepSeek-V3, Mixtral oder Command R+) mit wesentlich geringeren Hardwarekosten ausgeführt werden. Es wird nur ein Bruchteil der Parameter geladen und aktiv berechnet, was Speicherplatz spart und die Inferenz beschleunigt.
</p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[10px] text-foreground">
<span className="text-cyan-400">Vorteil:</span> GPT-4-Klasse Performance bei einem Bruchteil der aktiven VRAM-Last auf deinem Homelab!
</div>
</div>
</div>
{/* MCP Card */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-6 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-center gap-2 border-b border-border/20 pb-3">
<Compass className="h-5 w-5 text-violet-400" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">2. Model Context Protocol (MCP)</h3>
<span className="text-[9px] text-violet-400 font-mono">Standardisierte Agenten-Tools</span>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-2 leading-normal">
<p>
<strong>Was ist das?</strong> MCP ist ein offenes Protokoll (initiiert von Anthropic), das festlegt, wie ein KI-Client (z.B. Roo Code auf deinem PC) mit externen Datenquellen und Tools kommuniziert. Es funktioniert wie ein USB-Standard für KI.
</p>
<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="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>
{/* Skills Card */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-6 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-center gap-2 border-b border-border/20 pb-3">
<Brain className="h-5 w-5 text-emerald-400" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">3. Agent Skills</h3>
<span className="text-[9px] text-emerald-400 font-mono">Modulbasierte Fähigkeiten</span>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-2 leading-normal">
<p>
<strong>Was ist das?</strong> Ein Skill ist ein Verzeichnis mit standardisierten Anweisungen, Scripten und Beispielen, das deine Agenten für spezifische Aufgaben trainiert (z.B. Test-Driven Development, Code-Vereinfachung, API-Design).
</p>
<p>
<strong>Wie benutzt man sie?</strong> Lege einen Skill-Ordner unter <code>.agents/skills/&lt;name&gt;</code> in deinem Projekt an. Das Herzstück ist die Datei <code>SKILL.md</code> mit folgendem Aufbau:
</p>
<pre className="p-2.5 bg-background/25 rounded-lg border border-border/30 font-mono text-[9px] text-foreground overflow-x-auto whitespace-pre">
{`---
name: tdd-pro
description: Drive development with strict TDD practices
---
# Instructions
...`}
</pre>
<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>
{/* Hermes Card */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-6 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-center gap-2 border-b border-border/20 pb-3">
<Cpu className="h-5 w-5 text-indigo-400" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">4. Arbeiten mit Hermes</h3>
<span className="text-[9px] text-indigo-400 font-mono">Autonomer Box-Agent</span>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-2 leading-normal">
<p>
<strong>Was ist das?</strong> Hermes ist der auf der Box installierte, autonome Hintergrund-Agent. Er verwaltet das Dateisystem und kann über REST (Port 8642) oder eine interaktive ChatUI (Port 8787) gesteuert werden.
</p>
<p>
<strong>Best Practices für Hermes:</strong>
</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Chat-Kontext sauber halten:</strong> Starte regelmäßig neue Chats. Zu große Historien verlangsamen den Agenten und führen zu Halluzinationen.</li>
<li><strong>Gehirn festlegen:</strong> Konfiguriere im Gateway die Modell-Rolle <code>brain</code> für Hermes, damit er automatisch das passende Modell per Llama Swap lädt.</li>
<li><strong>Sandbox umgehen:</strong> Erweitere Hermes' System-Prompt (WebUI-Einstellungen), um Befehle über SSH an deinen Windows-PC zu leiten.</li>
</ul>
</div>
</div>
{/* Tools Best Practices */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-6 shadow-lg shadow-black/10 space-y-4 md:col-span-2">
<div className="flex items-center gap-2 border-b border-border/20 pb-3">
<Wrench className="h-5 w-5 text-amber-400" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">5. Richtiges Arbeiten mit Tools (Dateien, Terminal, DevTools)</h3>
<span className="text-[9px] text-amber-400 font-mono">Fehler vermeiden &amp; Kosten senken</span>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4 text-xs text-muted-foreground leading-normal">
<div className="space-y-1.5 p-3 bg-background/10 rounded-xl border border-border/30">
<h4 className="font-bold text-foreground flex items-center gap-1">
<Terminal className="h-3.5 w-3.5 text-primary" /> Terminal
</h4>
<p className="text-[11px]">
Verwende nur non-interaktive Befehle. Hänge bei langen Tasks (wie dev-Servern) ein <code>&amp;</code> an oder nutze die integrierte Job-Verwaltung. Verwende niemals interaktive Eingabeaufforderungen.
</p>
</div>
<div className="space-y-1.5 p-3 bg-background/10 rounded-xl border border-border/30">
<h4 className="font-bold text-foreground flex items-center gap-1">
<Code className="h-3.5 w-3.5 text-cyan-400" /> Dateimanager
</h4>
<p className="text-[11px]">
Überschreibe keine ganzen Dateien, wenn du nur eine Zeile ändern willst. Verwende gezielte Ersetzungs-Tools (wie <code>replace_file_content</code>). Das spart massiv Token-Kosten und beugt Fehlern vor.
</p>
</div>
<div className="space-y-1.5 p-3 bg-background/10 rounded-xl border border-border/30">
<h4 className="font-bold text-foreground flex items-center gap-1">
<Wrench className="h-3.5 w-3.5 text-violet-400" /> Browser DevTools
</h4>
<p className="text-[11px]">
Koppele deine Debug-Dienste mit dem Chrome-DevTools-Plugin. So kann der Agent Fehler in der Konsole live analysieren und das DOM verifizieren, anstatt blind zu raten.
</p>
</div>
</div>
</div>
{/* Autonomie Erklärung */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-6 shadow-lg shadow-black/10 space-y-4 md:col-span-2">
<div className="flex items-center gap-2 border-b border-border/20 pb-3">
<Shield className="h-5 w-5 text-emerald-400" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">Wie autonom ist Mission Control 2 wirklich?</h3>
<span className="text-[9px] text-emerald-400 font-mono">Die Grenze zwischen Automatisierung und Kontrolle</span>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-3 leading-normal">
<p>
Mission Control 2 ist als <strong>semi-autonomes Gateway</strong> konzipiert. Es besitzt die Fähigkeit, komplexe Workflows komplett selbstständig durchzuführen, unterliegt jedoch strikten Sicherheitsplanken:
</p>
<div className="grid sm:grid-cols-2 gap-4 pt-1">
<div className="space-y-1.5">
<h4 className="font-bold text-foreground flex items-center gap-1 text-[11px]">
<ArrowRight className="h-3 w-3 text-emerald-400" /> Was läuft vollautomatisch?
</h4>
<ul className="list-disc pl-4 space-y-1 text-[11px]">
<li>Modellaustausch (Routing) per llama-swap je nach Anfragetyp (z.B. Code vs. Reasoning).</li>
<li>Hintergrund-Synchronisation und Speicherung von Gedächtniseinträgen (Memory).</li>
<li>Modell-Installation und API-Key-Injektionen in Gateway-Verbindungen.</li>
</ul>
</div>
<div className="space-y-1.5">
<h4 className="font-bold text-foreground flex items-center gap-1 text-[11px]">
<ArrowRight className="h-3 w-3 text-amber-400" /> Wo ist menschliche Freigabe nötig?
</h4>
<ul className="list-disc pl-4 space-y-1 text-[11px]">
<li><strong>Systembefehle:</strong> Das Ausführen von Terminal-Kommandos auf deinem PC erfordert standardmäßig deine Bestätigung.</li>
<li><strong>Kritische Systemeingriffe:</strong> OS-Updates, Engine-Rebuilds und Host-Reboots müssen manuell über das Dashboard ausgelöst werden.</li>
<li><strong>Gedächtnis-Löschung:</strong> Permanentes Verwerfen von gesammelten Memorys wird von dir freigegeben.</li>
</ul>
</div>
</div>
<p className="text-[11px] bg-background/25 p-3 rounded-xl border border-border/30 mt-2">
<strong>Fazit:</strong> Der Stack erledigt die Kärrnerarbeit (Modelle tauschen, API-Adapter bereitstellen, Sandbox-Verbindungen herstellen) komplett im Hintergrund. Er agiert als dein persönlicher, treuer Copilot, ohne jemals ungefragt schädliche Operationen auf deinem Hauptsystem auszuführen.
</p>
</div>
</div>
</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>
)
}