feat: simplify Discover view into socket cards & backend upgrades

This commit is contained in:
Hitonabi
2026-06-26 07:53:41 +02:00
parent b90cef3968
commit bc3abb127a
21 changed files with 2019 additions and 715 deletions
+24 -16
View File
@@ -1,6 +1,6 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.""" """Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel from pydantic import BaseModel
from services import maintenance from services import maintenance
@@ -8,38 +8,46 @@ from services import maintenance
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class SudoReq(BaseModel):
sudo_password: str | None = None
class RestartReq(BaseModel):
service: str
sudo_password: str | None = None
@router.get("/maintenance/updates") @router.get("/maintenance/updates")
def updates() -> dict: def updates() -> dict:
return maintenance.updates() return maintenance.updates()
@router.post("/maintenance/os-update") @router.post("/maintenance/os-update")
def os_update() -> dict: def os_update(body: SudoReq) -> dict:
return {"job_id": maintenance.os_update_job()} res = maintenance.os_update_job(body.sudo_password)
if isinstance(res, dict) and not res.get("ok", True):
return res
return res
@router.post("/maintenance/engine-update") @router.post("/maintenance/engine-update")
def engine_update() -> dict: def engine_update(body: SudoReq) -> dict:
job = maintenance.engine_update_job() res = maintenance.engine_update_job(body.sudo_password)
if not job: if not res:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).") raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return {"job_id": job} return res
@router.post("/maintenance/reboot") @router.post("/maintenance/reboot")
def reboot() -> dict: def reboot(body: SudoReq) -> dict:
return maintenance.reboot() return maintenance.reboot(body.sudo_password)
class RestartReq(BaseModel):
service: str
@router.post("/maintenance/restart") @router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict: def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service) return maintenance.restart_service(body.service, body.sudo_password)
@router.get("/maintenance/logs") @router.get("/maintenance/logs")
def logs(service: str, lines: int = 200) -> dict: def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
return maintenance.logs(service, lines) return maintenance.logs(service, lines, x_sudo_password)
+43
View File
@@ -158,6 +158,49 @@ def set_model_ctx(model_id: str, body: CtxReq) -> dict:
return {"ok": True} return {"ok": True}
@router.post("/models/unload")
def unload_all_models() -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
with httpx.Client(timeout=10.0) as c:
r = c.post(f"{LLAMA_SWAP_URL}/api/models/unload")
return {"ok": r.status_code == 200}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.post("/models/{model_id}/unload")
def unload_model(model_id: str) -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
with httpx.Client(timeout=10.0) as c:
r = c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{model_id}")
return {"ok": r.status_code == 200}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.post("/models/{model_id}/load")
def load_model(model_id: str) -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
# Trigger load by sending a lightweight completion request.
body = {
"model": model_id,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
# High timeout because model loading might take time
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.delete("/models/{model_id}") @router.delete("/models/{model_id}")
def delete(model_id: str) -> dict: def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id): if not llamaswap.delete_model(model_id):
+4
View File
@@ -115,6 +115,10 @@ def capabilities(name: str = "", cmd: str = "", gguf_path: str = "", hf: dict |
m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl) m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl)
ctx = int(m.group(1)) if m else None ctx = int(m.group(1)) if m else None
# Cap context length at 131072 for Qwen / Hermes models to prevent reporting scaled RoPE context of 256k+ which might OOM or be unstable.
if ctx and ctx > 131072 and ("qwen" in low or "hermes" in low):
ctx = 131072
tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl
tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags
tools = "yes" if tool_confirmed else ("likely" if tool_family else "no") tools = "yes" if tool_confirmed else ("likely" if tool_family else "no")
+10 -2
View File
@@ -36,7 +36,7 @@ def build_snippets(host: str = DEFAULT_HOST,
}, indent=2) }, indent=2)
opencode = json.dumps({ opencode = json.dumps({
"providers": { "provider": {
"bosgame": { "bosgame": {
"npm": "@ai-sdk/openai-compatible", "npm": "@ai-sdk/openai-compatible",
"name": "Bosgame Gateway", "name": "Bosgame Gateway",
@@ -46,6 +46,12 @@ def build_snippets(host: str = DEFAULT_HOST,
} }
}, indent=2) }, indent=2)
cursor = json.dumps({
"Base URL": gw,
"API Key": "local",
"Active Model": "auto"
}, indent=2)
zed = json.dumps({ zed = json.dumps({
"language_models": { "language_models": {
"openai_compatible": { "openai_compatible": {
@@ -97,8 +103,10 @@ def build_snippets(host: str = DEFAULT_HOST,
"tools": { "tools": {
"cline": {"label": "Roo Code / Cline (VS Code)", "lang": "json", "snippet": cline, "cline": {"label": "Roo Code / Cline (VS Code)", "lang": "json", "snippet": cline,
"note": "OpenAI-Provider → Gateway. Modell 'auto' (schnell, eskaliert bei Bedarf)."}, "note": "OpenAI-Provider → Gateway. Modell 'auto' (schnell, eskaliert bei Bedarf)."},
"cursor": {"label": "Cursor IDE", "lang": "json", "snippet": cursor,
"note": "Einstellungen ➔ Models ➔ OpenAI API key + Base URL."},
"opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode, "opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode,
"note": "Datei opencode.jsonc, Key 'providers'."}, "note": "Datei opencode.jsonc, Key 'provider'."},
"zed": {"label": "Zed", "lang": "json", "snippet": zed, "zed": {"label": "Zed", "lang": "json", "snippet": zed,
"note": "settings.json → language_models.openai_compatible."}, "note": "settings.json → language_models.openai_compatible."},
"continue": {"label": "Continue", "lang": "json", "snippet": cont, "continue": {"label": "Continue", "lang": "json", "snippet": cont,
+27 -5
View File
@@ -57,19 +57,39 @@ def _pump_output(job: dict, stream) -> None:
commit() commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None): def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
job = JOBS[job_id] job = JOBS[job_id]
job["state"] = "running" job["state"] = "running"
try: try:
actual_args = list(args)
if sudo_password is not None:
for i, arg in enumerate(actual_args):
if isinstance(arg, str):
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
proc = subprocess.Popen( proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None,
bufsize=0,
env={**os.environ, **(env or {})}, env={**os.environ, **(env or {})},
) )
_PROCS[job_id] = proc _PROCS[job_id] = proc
if sudo_password is not None and proc.stdin:
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
proc.stdin.flush()
proc.stdin.close()
_pump_output(job, proc.stdout) _pump_output(job, proc.stdout)
proc.wait() proc.wait()
job["returncode"] = proc.returncode job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed") job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
# Check if failed due to sudo authorization failure
if proc.returncode != 0 and job["log"]:
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Fehler: {exc}") _append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed" job["state"] = "failed"
@@ -127,16 +147,18 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
threading.Thread(target=_watch, daemon=True).start() threading.Thread(target=_watch, daemon=True).start()
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None) -> str: def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, sudo_password: str | None = None) -> str:
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
# Mask password in log if present in args
log_args = list(args)
JOBS[job_id] = { JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "id": job_id, "label": label, "state": "queued",
"log": ["$ " + " ".join(shlex.quote(a) for a in args)], "log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
"returncode": None, "started_at": time.time(), "finished_at": None, "returncode": None, "started_at": time.time(), "finished_at": None,
} }
if on_done: if on_done:
JOBS[job_id]["_on_done"] = on_done JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start() threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
return job_id return job_id
+62 -15
View File
@@ -83,45 +83,92 @@ def updates() -> dict:
"models": len(ups), "model_list": ups} "models": len(ups), "model_list": ups}
def _run(cmd: list[str]) -> dict: def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
actual_cmd = list(cmd)
has_sudo = False
if cmd and cmd[0] == "sudo":
has_sudo = True
# If we have a password, use -S instead of -n
if sudo_password is not None:
if "-n" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-n"]
if "-S" not in actual_cmd:
actual_cmd.insert(1, "-S")
else:
# Force -n to fail cleanly if password is required
if "-S" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-S"]
if "-n" not in actual_cmd:
actual_cmd.insert(1, "-n")
try: try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=120) input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
err_msg = p.stderr or ""
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
if sudo_password is not None:
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]} return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return {"ok": False, "out": "", "err": str(exc)} return {"ok": False, "out": "", "err": str(exc)}
def restart_service(name: str) -> dict: def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
res = _run(["sudo", "true"], sudo_password=sudo_password)
if not res["ok"]:
return res
return None
def restart_service(name: str, sudo_password: str | None = None) -> dict:
if name in SYSTEM_SERVICES: if name in SYSTEM_SERVICES:
return _run(["sudo", "-n", "systemctl", "restart", name]) if err := check_sudo_needs_password(sudo_password):
return err
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
if name in USER_SERVICES: if name in USER_SERVICES:
return _run(["systemctl", "--user", "restart", name]) return _run(["systemctl", "--user", "restart", name])
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."} return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
def logs(service: str, lines: int = 200) -> dict: def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
lines = max(1, min(lines, 1000)) lines = max(1, min(lines, 1000))
if service in SYSTEM_SERVICES: if service in SYSTEM_SERVICES:
cmd = ["sudo", "-n", "journalctl", "-u", service, "-n", str(lines), "--no-pager"] if err := check_sudo_needs_password(sudo_password):
return err
cmd = ["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"]
r = _run(cmd, sudo_password=sudo_password)
elif service in USER_SERVICES: elif service in USER_SERVICES:
cmd = ["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"] cmd = ["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]
r = _run(cmd)
else: else:
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
r = _run(cmd)
return {"ok": r["ok"], "text": r["out"] or r["err"]} return {"ok": r["ok"], "text": r["out"] or r["err"]}
def os_update_job() -> str: def os_update_job(sudo_password: str | None = None) -> dict:
cmd = "sudo -n apt-get update && sudo -n DEBIAN_FRONTEND=noninteractive apt-get upgrade -y" if err := check_sudo_needs_password(sudo_password):
return jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)") return err
cmd = "sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", sudo_password=sudo_password)
return {"ok": True, "job_id": job_id}
def engine_update_job() -> str | None: def engine_update_job(sudo_password: str | None = None) -> dict | None:
if not ENGINE_UPDATE_CMD: if not ENGINE_UPDATE_CMD:
return None return None
cmd = f"{ENGINE_UPDATE_CMD} && sudo -n systemctl restart llama-swap" if err := check_sudo_needs_password(sudo_password):
return jobengine.start_job(["bash", "-c", cmd], "Engine-Update (llama.cpp)") return err
cmd = f"{ENGINE_UPDATE_CMD} && sudo systemctl restart llama-swap"
job_id = jobengine.start_job(["bash", "-c", cmd], "Engine-Update (llama.cpp)", sudo_password=sudo_password)
return {"ok": True, "job_id": job_id}
def reboot() -> dict: def reboot(sudo_password: str | None = None) -> dict:
return _run(["sudo", "-n", "reboot"]) if err := check_sudo_needs_password(sudo_password):
return err
return _run(["sudo", "reboot"], sudo_password=sudo_password)
+107
View File
@@ -8,6 +8,7 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
import glob import glob
import os import os
import subprocess
import psutil import psutil
@@ -65,6 +66,111 @@ def _temps() -> dict | None:
return out or None return out or None
def get_git_info(path: str) -> dict | None:
expanded = os.path.expanduser(path)
if not os.path.isdir(expanded) or not os.path.exists(os.path.join(expanded, ".git")):
return None
try:
res = subprocess.run(
["git", "log", "-1", "--format=%h|%cd|%s", "--date=short"],
cwd=expanded, capture_output=True, text=True, timeout=3
)
if res.returncode != 0:
return None
parts = res.stdout.strip().split("|", 2)
h = parts[0]
d = parts[1]
s = parts[2] if len(parts) > 2 else ""
branch_res = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=expanded, capture_output=True, text=True, timeout=2
)
branch = branch_res.stdout.strip() if branch_res.returncode == 0 else "unknown"
status_res = subprocess.run(
["git", "status", "--porcelain"],
cwd=expanded, capture_output=True, text=True, timeout=2
)
dirty = bool(status_res.stdout.strip()) if status_res.returncode == 0 else False
return {
"hash": h,
"date": d,
"subject": s,
"branch": branch,
"dirty": dirty,
"path": expanded
}
except Exception:
return None
def find_hermes_agent_git() -> dict | None:
env_path = os.environ.get("MC_HERMES_AGENT_PATH")
if env_path:
info = get_git_info(env_path)
if info:
return info
candidates = [
"~/hermes-agent",
"~/.hermes/hermes-agent",
"~/hermes-webui/hermes-agent",
"~/.hermes"
]
for c in candidates:
info = get_git_info(c)
if info:
return info
return None
def get_engine_version() -> dict:
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
git_info = get_git_info(engine_path)
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"
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
if res.returncode == 0:
lines = res.stdout.strip().splitlines()
ver = lines[0] if lines else "unknown"
return {"version_text": ver, "type": "binary"}
except Exception:
pass
return {"type": "unknown"}
_VERSION_CACHE = {"ts": 0.0, "data": {}}
def check_versions_cached() -> dict:
import time
now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"]
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
data = {
"mc2": get_git_info(mc2_path),
"engine": get_engine_version(),
"hermes_ui": get_git_info("~/hermes-webui"),
"hermes_agent": find_hermes_agent_git()
}
_VERSION_CACHE["ts"] = now
_VERSION_CACHE["data"] = data
return data
def system_status() -> dict: def system_status() -> dict:
vm = psutil.virtual_memory() vm = psutil.virtual_memory()
try: try:
@@ -78,4 +184,5 @@ def system_status() -> dict:
"gpu": _gpu_sysfs(), "gpu": _gpu_sysfs(),
"temp": _temps(), "temp": _temps(),
"disk": disk, "disk": disk,
"versions": check_versions_cached(),
} }
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
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="mc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#06b6d4" />
<stop offset="50%" stop-color="#0d9488" />
<stop offset="100%" stop-color="#3b82f6" />
</linearGradient>
</defs>
<!-- Background Shield or Rounded Square -->
<rect x="10" y="10" width="80" height="80" rx="20" fill="url(#mc-grad)" />
<!-- Central Terminal or Graph Symbol -->
<path d="M 30 40 L 45 50 L 30 60" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
<line x1="55" y1="60" x2="70" y2="60" stroke="#ffffff" stroke-width="8" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 753 B

+3 -2
View File
@@ -5,9 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" /> <meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DMajYfAZ.js"></script> <script type="module" crossorigin src="/assets/index-DbuXer0L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CAfaSDN8.css"> <link rel="stylesheet" crossorigin href="/assets/index-B7OvRZwV.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" /> <meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
</head> </head>
<body> <body>
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="mc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#06b6d4" />
<stop offset="50%" stop-color="#0d9488" />
<stop offset="100%" stop-color="#3b82f6" />
</linearGradient>
</defs>
<!-- Background Shield or Rounded Square -->
<rect x="10" y="10" width="80" height="80" rx="20" fill="url(#mc-grad)" />
<!-- Central Terminal or Graph Symbol -->
<path d="M 30 40 L 45 50 L 30 60" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
<line x1="55" y1="60" x2="70" y2="60" stroke="#ffffff" stroke-width="8" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 753 B

+44 -48
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Command as CommandIcon, Moon, Sun, ChevronLeft, ChevronRight, Cpu } from "lucide-react" import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { NAV, type ViewId } from "@/nav" import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette" import { CommandPalette } from "@/components/CommandPalette"
import { DashboardView } from "@/views/DashboardView" import { DashboardView } from "@/views/DashboardView"
@@ -11,16 +11,16 @@ import { AgentView } from "@/views/AgentView"
import { GuideView } from "@/views/GuideView" import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer" import { SystemDrawer } from "@/components/SystemDrawer"
import { api, type Health, type UpdatesResp } from "@/lib/api" import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export default function App() { export default function App() {
const [view, setView] = useState<ViewId>("dashboard") const [view, setView] = useState<ViewId>("dashboard")
const [health, setHealth] = useState<Health | null>(null) const [health, setHealth] = useState<Health | null>(null)
const [dark, setDark] = useState(() => localStorage.getItem("mc_theme") !== "light")
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [updates, setUpdates] = useState<UpdatesResp | null>(null) const [sysStatus, setSysStatus] = useState<any | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
useEffect(() => { useEffect(() => {
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null)) const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null))
@@ -30,25 +30,27 @@ export default function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
const loadUpdates = () => api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {}) const loadSys = () => api<any>("/api/system/status").then(setSysStatus).catch(() => {})
loadUpdates() loadSys()
const t = setInterval(loadUpdates, 20000) const t = setInterval(loadSys, 20000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", dark) document.documentElement.classList.add("dark")
localStorage.setItem("mc_theme", dark ? "dark" : "light") }, [])
}, [dark])
useEffect(() => { useEffect(() => {
const handleOpen = () => setDrawerOpen(true) const handleOpen = (e: Event) => {
const customEvent = e as CustomEvent
setDrawerTab(customEvent.detail?.tab || "maintenance")
setDrawerOpen(true)
}
window.addEventListener("open-system-drawer", handleOpen) window.addEventListener("open-system-drawer", handleOpen)
return () => window.removeEventListener("open-system-drawer", handleOpen) return () => window.removeEventListener("open-system-drawer", handleOpen)
}, []) }, [])
const active = NAV.find((n) => n.id === view)! const active = NAV.find((n) => n.id === view)!
const totalUpdates = updates ? (updates.os + updates.engine + updates.models) : 0
return ( return (
<div className="flex h-full relative"> <div className="flex h-full relative">
@@ -61,7 +63,7 @@ export default function App() {
</div> </div>
<CommandPalette onNavigate={setView} /> <CommandPalette onNavigate={setView} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} /> <SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar */} {/* Sidebar */}
<aside className={cn( <aside className={cn(
@@ -122,16 +124,35 @@ export default function App() {
)} title={health ? `Engine ${health.engine_reachable ? "online" : "offline"}` : "Backend offline"} /> )} title={health ? `Engine ${health.engine_reachable ? "online" : "offline"}` : "Backend offline"} />
</div> </div>
) : ( ) : (
health ? ( <div className="space-y-2 text-left">
<span className="flex items-center gap-2"> {health ? (
<span className={cn("h-2 w-2 rounded-full", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} /> <span className="flex items-center gap-2">
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"} · v{health.version}</span> <span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
</span> <span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
) : ( </span>
<span className="flex items-center gap-2"> ) : (
<span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span> <span className="flex items-center gap-2">
</span> <span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span>
) </span>
)}
{sysStatus?.versions && (
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
<div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}>
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</div>
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}>
<strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
</div>
<div className="truncate" title={sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.branch}-${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""} (${sysStatus.versions.hermes_ui.date})` : "nicht gefunden"}>
<strong>Hermes UI:</strong> {sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""}` : "—"}
</div>
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}>
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div>
</div>
)}
</div>
)} )}
</div> </div>
</aside> </aside>
@@ -141,21 +162,6 @@ export default function App() {
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm"> <header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div> <div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => setDrawerOpen(true)}
className="relative flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-3 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground gap-1.5 transition-all cursor-pointer font-semibold"
title="System-Pflege öffnen"
>
<Cpu className="h-3.5 w-3.5" />
<span>OS & Updates</span>
{totalUpdates > 0 && (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-500"></span>
</span>
)}
</button>
<a <a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md" href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank" target="_blank"
@@ -166,14 +172,6 @@ export default function App() {
Hilfe Hilfe
</a> </a>
<button
onClick={() => setDark((d) => !d)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-background/40 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
title="Hell/Dunkel"
>
{dark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
<button <button
onClick={() => { onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true }) const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
@@ -201,8 +199,6 @@ export default function App() {
)} )}
</main> </main>
</div> </div>
</div> </div>
) )
} }
+8 -1
View File
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"
interface SystemDrawerProps { interface SystemDrawerProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
defaultTab?: "maintenance" | "logs"
} }
const SERVICES = [ const SERVICES = [
@@ -22,7 +23,7 @@ function formatBytes(bytes?: number) {
return `${(bytes / 1024 ** 2).toFixed(1)} MB` return `${(bytes / 1024 ** 2).toFixed(1)} MB`
} }
export function SystemDrawer({ open, onClose }: SystemDrawerProps) { export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const [updates, setUpdates] = useState<UpdatesResp | null>(null) const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [selectedService, setSelectedService] = useState("llama-swap") const [selectedService, setSelectedService] = useState("llama-swap")
@@ -30,6 +31,12 @@ export function SystemDrawer({ open, onClose }: SystemDrawerProps) {
const [loadingLogs, setLoadingLogs] = useState(false) const [loadingLogs, setLoadingLogs] = useState(false)
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({}) const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<"maintenance" | "logs">("maintenance") const [activeTab, setActiveTab] = useState<"maintenance" | "logs">("maintenance")
useEffect(() => {
if (open && defaultTab) {
setActiveTab(defaultTab)
}
}, [open, defaultTab])
const logContainerRef = useRef<HTMLPreElement | null>(null) const logContainerRef = useRef<HTMLPreElement | null>(null)
+440 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield } from "lucide-react" import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield, X, Check, Copy } from "lucide-react"
import { api, type AgentStatus } from "@/lib/api" import { api, type AgentStatus, type ModelInfo, type RoutingResp, type ConnectResp } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; detail?: string; icon: any }) { function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; detail?: string; icon: any }) {
@@ -30,19 +30,88 @@ function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; d
) )
} }
const ROLES = ["fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
export function AgentView() { export function AgentView() {
const [s, setS] = useState<AgentStatus | null>(null) const [s, setS] = useState<AgentStatus | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
// Graph states
const [models, setModels] = useState<ModelInfo[]>([])
const [running, setRunning] = useState<string[]>([])
const [routing, setRouting] = useState<RoutingResp | null>(null)
const [connectData, setConnectData] = useState<ConnectResp | null>(null)
// Graph UI states
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleDrop, setActiveRoleDrop] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
function loadData() {
api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
Promise.all([
api<{ models: ModelInfo[]; running?: string[] }>("/api/models"),
api<RoutingResp>("/api/routing"),
api<ConnectResp>("/api/connect")
])
.then(([mResp, rResp, cResp]) => {
setModels(mResp.models || [])
setRunning(mResp.running || [])
setRouting(rResp)
setConnectData(cResp)
})
.catch((e) => console.error("Error loading graph data in AgentView", e))
}
useEffect(() => { useEffect(() => {
const load = () => api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e))) loadData()
load() const t = setInterval(loadData, 5000)
const t = setInterval(load, 5000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
const getModelForRole = (role: string) => models.find((m) => m.role === role)
const isRoleRunning = (role: string) => {
const m = getModelForRole(role)
return m ? running.includes(m.name) : false
}
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 }),
})
loadData()
} catch (e) {
alert(`Fehler beim Zuweisen der Rolle: ${e}`)
}
}
async function copySnippet(snippet?: string) {
if (!snippet) return
await navigator.clipboard.writeText(snippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Styles inside AgentView for dash flow animations */}
<style>{`
@keyframes flow-dash {
to {
stroke-dashoffset: -40;
}
}
.animate-flow-cyan {
stroke-dasharray: 8 24;
animation: flow-dash 1.2s linear infinite;
}
`}</style>
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4"> <div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div> <div>
@@ -108,9 +177,374 @@ export function AgentView() {
/> />
</div> </div>
{/* Interactive Gateway Graph */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-4">
<div>
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Interactive Gateway Graph (Duplikat)</span>
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
Visualisiert die Kopplung der Client-Editoren mit dem OpenAI-Gateway und den aktiven Modell-Rollen.
</p>
</div>
{/* The Graph Canvas Area */}
<div className="relative w-full h-[360px] border border-border/30 rounded-xl bg-black/25 overflow-hidden flex">
<svg className="absolute inset-0 pointer-events-none w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<linearGradient id="cyan-to-teal" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0.45" />
<stop offset="100%" stopColor="#0d9488" stopOpacity="0.45" />
</linearGradient>
<linearGradient id="active-glow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.8" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.8" />
</linearGradient>
</defs>
{/* Bezier Curves: Clients to Gateway */}
{/* Roo Code (y=10) */}
<path d="M 10 10 C 30 10, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "roocode" || activeClient === "roocode") && (
<path d="M 10 10 C 30 10, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* Cursor (y=30) */}
<path d="M 10 30 C 30 30, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "cursor" || activeClient === "cursor") && (
<path d="M 10 30 C 30 30, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* OpenCode (y=50) */}
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "opencode" || activeClient === "opencode") && (
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* Zed (y=70) */}
<path d="M 10 70 C 30 70, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "zed" || activeClient === "zed") && (
<path d="M 10 70 C 30 70, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* Continue (y=90) */}
<path d="M 10 90 C 30 90, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "continue" || activeClient === "continue") && (
<path d="M 10 90 C 30 90, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* Bezier Curves: Gateway to Roles */}
{/* fast (y=12) */}
<path d="M 50 50 C 70 50, 70 12, 90 12" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("fast") && (
<path d="M 50 50 C 70 50, 70 12, 90 12" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* heavy (y=31) */}
<path d="M 50 50 C 70 50, 70 31, 90 31" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("heavy") && (
<path d="M 50 50 C 70 50, 70 31, 90 31" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* coder (y=50) */}
<path d="M 50 50 C 70 50, 70 50, 90 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("coder") && (
<path d="M 50 50 C 70 50, 70 50, 90 50" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* vision (y=69) */}
<path d="M 50 50 C 70 50, 70 69, 90 69" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("vision") && (
<path d="M 50 50 C 70 50, 70 69, 90 69" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* scout (y=88) */}
<path d="M 50 50 C 70 50, 70 88, 90 88" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("scout") && (
<path d="M 50 50 C 70 50, 70 88, 90 88" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
</svg>
{/* Client Nodes */}
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "10%" }}
onMouseEnter={() => setHoveredNode("roocode")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "roocode" ? null : "roocode")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Roo Code</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "30%" }}
onMouseEnter={() => setHoveredNode("cursor")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "cursor" ? null : "cursor")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Cursor IDE</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "50%" }}
onMouseEnter={() => setHoveredNode("opencode")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "opencode" ? null : "opencode")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>OpenCode</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "70%" }}
onMouseEnter={() => setHoveredNode("zed")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "zed" ? null : "zed")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Zed</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "90%" }}
onMouseEnter={() => setHoveredNode("continue")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "continue" ? null : "continue")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Continue</span>
</div>
{/* Gateway Node */}
<div
className="absolute select-none z-10 w-36 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5"
style={{ left: "50%", top: "50%" }}
>
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div>
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
Schwelle: &gt; {routing?.heavy_threshold_chars ? (routing.heavy_threshold_chars / 1000) : "4"}k Zeichen
</div>
<div className="mt-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-semibold text-primary uppercase font-mono">
Auto-Swap
</div>
</div>
{/* Role Nodes */}
{ROLES.map((role) => {
const yPositions = ["12%", "31%", "50%", "69%", "88%"]
const activeModel = getModelForRole(role)
const isWarm = activeModel ? running.includes(activeModel.name) : false
if (role === "reasoning" || role === "agent") return null
const indexMap = { fast: 0, heavy: 1, coder: 2, vision: 3, scout: 4 }[role] as number
return (
<div
key={role}
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 cursor-pointer",
isWarm
? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5"
: activeModel
? "border-border/60 bg-card/75"
: "border-dashed border-border/40 bg-background/20"
)}
style={{ left: "90%", top: yPositions[indexMap] }}
onClick={() => setActiveRoleDrop(activeRoleDrop === role ? null : role)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
{isWarm && <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 max-w-[150px]">
{activeModel ? activeModel.name.split("/").pop()?.replace(".gguf", "") : "Keine Zuweisung"}
</div>
{/* Zuweisung Popover */}
{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>
)
})}
{/* Floating Setup Popover */}
{activeClient && connectData && (
<div
className="absolute z-30 md:w-96 w-[90%] rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl space-y-3 flex flex-col justify-between"
style={{
left: "22%",
top: activeClient === "roocode" ? "5%"
: activeClient === "cursor" ? "20%"
: activeClient === "opencode" ? "40%"
: activeClient === "zed" ? "55%"
: "65%"
}}
>
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-primary">
{activeClient === "roocode" && "Roo Code Setup"}
{activeClient === "cursor" && "Cursor Setup"}
{activeClient === "opencode" && "OpenCode Setup"}
{activeClient === "zed" && "Zed Setup"}
{activeClient === "continue" && "Continue Setup"}
</span>
<button
onClick={() => setActiveClient(null)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="text-[10px] text-muted-foreground leading-relaxed space-y-2">
{activeClient === "roocode" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Suche in VS Code nach der Erweiterung <strong>Roo Code</strong> und installiere sie.</li>
<li>Wähle in den Roo Code Einstellungen: Provider: <strong>OpenAI Compatible</strong>.</li>
<li>Füge das untenstehende JSON-Snippet in die <code>settings.json</code> ein.</li>
</ul>
)}
{activeClient === "cursor" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne Cursor Settings <strong>Models</strong>.</li>
<li>Deaktiviere Cloud-Modelle, klappe <strong>OpenAI API</strong> auf.</li>
<li>Trage die Base URL unten ein und aktiviere das Modell <strong>auto</strong>.</li>
</ul>
)}
{activeClient === "opencode" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne die <code>opencode.jsonc</code> Konfigurationsdatei.</li>
<li>Ersetze den Provider-Eintrag unter <code>provider</code> mit dem Snippet unten.</li>
</ul>
)}
{activeClient === "zed" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne die Zed Settings (<code>ctrl+,</code>).</li>
<li>Füge das untenstehende JSON-Segment unter <code>language_models</code> ein.</li>
</ul>
)}
{activeClient === "continue" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Klicke auf das Zahnrad-Symbol in der Continue-Erweiterung.</li>
<li>Füge den Gateway-Eintrag zum <code>models</code>-Array hinzu.</li>
</ul>
)}
</div>
{connectData.tools && (
<div className="relative rounded-xl border border-border/40 bg-black/40 overflow-hidden mt-1 shrink-0">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/20 bg-black/20">
<span className="text-[8px] font-mono text-muted-foreground">JSON Config</span>
<button
onClick={() => copySnippet(
activeClient === "roocode" ? connectData.tools.cline?.snippet :
activeClient === "cursor" ? connectData.tools.cursor?.snippet :
activeClient === "opencode" ? connectData.tools.opencode?.snippet :
activeClient === "zed" ? connectData.tools.zed?.snippet :
connectData.tools.continue?.snippet
)}
className="text-[9px] font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer"
>
{copied ? <Check className="h-3 w-3 text-emerald-400" /> : <Copy className="h-3 w-3" />}
<span>{copied ? "Kopiert" : "Kopieren"}</span>
</button>
</div>
<pre className="p-3 max-h-36 overflow-y-auto text-[9px] font-mono text-cyan-200/90 whitespace-pre scrollbar-thin select-text">
<code>
{activeClient === "roocode" && connectData.tools.cline?.snippet}
{activeClient === "cursor" && connectData.tools.cursor?.snippet}
{activeClient === "opencode" && connectData.tools.opencode?.snippet}
{activeClient === "zed" && connectData.tools.zed?.snippet}
{activeClient === "continue" && connectData.tools.continue?.snippet}
</code>
</pre>
</div>
)}
</div>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-6 mt-3 text-[10px] text-muted-foreground border-t border-border/20 pt-3">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-cyan-400" />
<span>Cyan-Fluss: Client-Anfrage an Gateway</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span>Grüner Puls: Aktive Verbindung / Warmes Modell geladen</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded bg-gradient-to-r from-teal-500 to-emerald-500" />
<span>VRAM-Verlauf: Modellspezifischer Speicheranteil</span>
</span>
</div>
</div>
{/* SSH / Sandbox-Hilfe Card */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">SSH-Kopplung &amp; Sandbox-Umgehung</h3>
</div>
<div className="grid gap-6 md:grid-cols-2 text-xs text-muted-foreground leading-relaxed text-left">
<div className="space-y-3">
<h4 className="text-xs font-bold text-foreground">1. Warum verweigert Hermes Aktionen auf dem PC?</h4>
<p>
Hermes ist standardmäßig so ausgerichtet, dass er sich als virtueller Assistent ohne Systemzugriff versteht.
Um dies zu umgehen, musst du Hermes' **System-Prompt** in der Chat-WebUI anpassen.
</p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[9px] text-cyan-300">
<span className="text-muted-foreground/60">Beispiel System-Prompt Erweiterung:</span>
<br />
"Du hast über das terminal-Tool vollen SSH-Zugriff auf den Windows-PC des Users (TobisPC). Nutze SSH-Befehle um Aktionen dort auszuführen, wenn danach gefragt wird. Behaupte nie, du könntest das nicht!"
</div>
</div>
<div className="space-y-3">
<h4 className="text-xs font-bold text-foreground">2. SSH-Verbindung zum lokalen Windows-PC einrichten</h4>
<p>
Damit der Agent Befehle auf deinem PC ausführen kann, muss OpenSSH auf Windows aktiv und mit einem Key gekoppelt sein:
</p>
<ul className="list-disc pl-4 space-y-1.5 text-[11px]">
<li><strong>OpenSSH Server auf Windows starten:</strong> In PowerShell als Admin ausführen: <code className="text-foreground font-mono">Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0</code></li>
<li><strong>SSH-Key auf der Box erzeugen:</strong> <code className="text-foreground font-mono">ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_hermes_agent</code></li>
<li><strong>Key autorisieren:</strong> Kopiere den Inhalt von <code className="text-foreground font-mono">~/.ssh/id_ed25519_hermes_agent.pub</code> in deine Windows-Datei <code className="text-foreground font-mono">C:\Users\TobisPC\.ssh\authorized_keys</code></li>
</ul>
</div>
</div>
</div>
{/* Diagnostic Details if Offline */} {/* Diagnostic Details if Offline */}
{!s.gateway_reachable && ( {!s.gateway_reachable && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10 text-left">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-500" /> <Shield className="h-5 w-5 text-amber-500" />
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">Hermes-Agent Diagnostics</h3> <h3 className="text-sm font-bold uppercase tracking-wider text-foreground">Hermes-Agent Diagnostics</h3>
+241 -11
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert } from "lucide-react" import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp } from "@/lib/api" import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
function gb(b: number) { function gb(b: number) {
@@ -40,6 +40,20 @@ export function DashboardView() {
const [running, setRunning] = useState<string[]>([]) const [running, setRunning] = useState<string[]>([])
const [memories, setMemories] = useState<Memory[]>([]) const [memories, setMemories] = useState<Memory[]>([])
const [updates, setUpdates] = useState<UpdatesResp | null>(null) const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [jobs, setJobs] = useState<Job[]>([])
// Sudo & Action states
const [msg, setMsg] = useState("")
const [loading, setLoading] = useState(false)
const [sudoPassword, setSudoPassword] = useState("")
const [sudoLoading, setSudoLoading] = useState(false)
const [sudoModal, setSudoModal] = useState<{
open: boolean
actionPath: string
actionLabel: string
payload?: any
error?: string
}>({ open: false, actionPath: "", actionLabel: "" })
// Quick Memory Form State // Quick Memory Form State
const [memContent, setMemContent] = useState("") const [memContent, setMemContent] = useState("")
@@ -57,6 +71,7 @@ export function DashboardView() {
.catch(() => {}) .catch(() => {})
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {}) api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {}) api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {})
api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs || [])).catch(() => {})
} }
useEffect(() => { useEffect(() => {
@@ -65,6 +80,96 @@ export function DashboardView() {
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
async function postAction(path: string, label: string, payload?: any, password?: string) {
setMsg(`${label} wird ausgeführt...`)
setLoading(true)
try {
const body: any = { ...payload }
if (password) {
body.sudo_password = password
}
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal({
open: true,
actionPath: path,
actionLabel: label,
payload,
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
})
setMsg("")
return
}
if (r.job_id) {
setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
} else if (r.ok) {
setMsg(`${label} erfolgreich ausgeführt.`)
} else {
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
}
loadData()
} catch (e: any) {
setMsg(`Fehler bei ${label}: ${e.message}`)
} finally {
setLoading(false)
}
}
async function handleSudoSubmit() {
setSudoLoading(true)
try {
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal(prev => ({
...prev,
error: "Falsches Sudo-Passwort. Bitte erneut versuchen."
}))
return
}
if (r.job_id) {
setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
} else if (r.ok) {
setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
} else {
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
}
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
loadData()
} catch (e: any) {
setMsg(`Fehler: ${e.message}`)
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
} finally {
setSudoLoading(false)
}
}
async function upgradeModel(repo: string, role: string) {
setMsg(`Upgrade für ${repo} wird gestartet...`)
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
})
setMsg(`Upgrade-Download gestartet.`)
loadData()
} catch (e: any) {
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
}
}
async function saveQuickMemory() { async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return if (!memContent.trim() || savingMem) return
setSavingMem(true) setSavingMem(true)
@@ -84,8 +189,70 @@ export function DashboardView() {
const activeModels = models.filter((m) => m.role) const activeModels = models.filter((m) => m.role)
// Active updates check
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sudo Password Dialog Modal */}
{sudoModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
<button
onClick={() => {
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
}}
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-[10px] text-muted-foreground leading-normal">
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
</p>
<div className="space-y-2">
<input
type="password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
placeholder="Sudo-Passwort eingeben..."
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
autoFocus
/>
{sudoModal.error && (
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
}}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
>
Abbrechen
</button>
<button
onClick={handleSudoSubmit}
disabled={!sudoPassword || sudoLoading}
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
>
{sudoLoading ? "Prüfe..." : "Ausführen"}
</button>
</div>
</div>
</div>
)}
{/* Title */} {/* Title */}
<div> <div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent"> <h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
@@ -105,7 +272,7 @@ export function DashboardView() {
</div> </div>
{sys ? ( {sys ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RadialGauge value={sys.cpu.percent} label="CPU" detail={`${sys.cpu.cores} Cores`} /> <RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} /> <RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && ( {sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
<RadialGauge <RadialGauge
@@ -132,14 +299,14 @@ export function DashboardView() {
{/* Card 2: Updates & Wartung (1 column wide) */} {/* Card 2: Updates & Wartung (1 column wide) */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div> <div className="space-y-4">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2">
<ShieldAlert className="h-4.5 w-4.5 text-primary" /> <ShieldAlert className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates &amp; Pflege</h2> <h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates &amp; Pflege</h2>
</div> </div>
{updates ? ( {updates ? (
<div className="space-y-2.5"> <div className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className={cn( <div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs", "flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
@@ -172,13 +339,63 @@ export function DashboardView() {
</div> </div>
</div> </div>
{/* Inline Action Buttons */}
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
<button
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
disabled={loading || !!activeOsJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeOsJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
</>
) : (
<span>OS Update</span>
)}
</button>
<button
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
disabled={loading || !!activeEngineJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeEngineJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
</>
) : (
<span>Engine Update</span>
)}
</button>
</div>
<button
onClick={() => { if (confirm("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"
>
<Power className="h-3.5 w-3.5" />
<span>Host Reboot</span>
</button>
{/* Model Upgrades list */}
{updates.model_list.length > 0 && ( {updates.model_list.length > 0 && (
<div className="mt-2 space-y-1"> <div className="space-y-1.5 border-t border-border/20 pt-3">
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Upgrades:</div> <div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin"> <div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
{updates.model_list.map((m) => ( {updates.model_list.map((m) => (
<div key={m.repo} className="text-[9px] bg-background/20 border border-border/20 rounded p-1.5 font-mono text-muted-foreground truncate" title={`${m.role}: ${m.repo}`}> <div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
<span className="text-primary font-semibold uppercase">{m.role}</span>: {m.repo.split("/").pop()} <span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
</span>
<button
onClick={() => upgradeModel(m.repo, m.role)}
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
>
<Download className="h-2.5 w-2.5" /> Laden
</button>
</div> </div>
))} ))}
</div> </div>
@@ -188,6 +405,19 @@ export function DashboardView() {
) : ( ) : (
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div> <div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
)} )}
{/* Status messages display */}
{msg && (
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
{msg}
</div>
)}
{/* Sudoers explanation text */}
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
</div>
</div> </div>
<div className="mt-4 border-t border-border/30 pt-3 shrink-0"> <div className="mt-4 border-t border-border/30 pt-3 shrink-0">
+615 -165
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X, Check, Copy } from "lucide-react" import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X, Check, Copy, Bot, Eye, Code, Brain, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo, type RoutingResp, type UpdatesResp, type ConnectResp } from "@/lib/api" 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 { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -112,6 +112,18 @@ function FitBadge({ fit }: { fit: Fit }) {
const ROLES = ["fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"] const ROLES = ["fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
function getBrandInfo(name: string) {
const low = name.toLowerCase()
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
}
function Cockpit() { function Cockpit() {
const [models, setModels] = useState<ModelInfo[]>([]) const [models, setModels] = useState<ModelInfo[]>([])
const [running, setRunning] = useState<string[]>([]) const [running, setRunning] = useState<string[]>([])
@@ -122,10 +134,11 @@ function Cockpit() {
const [error, setError] = useState("") const [error, setError] = useState("")
// UI state // UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | null>(null) const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleDrop, setActiveRoleDrop] = useState<string | null>(null) const [activeRoleDrop, setActiveRoleDrop] = useState<string | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [hoveredNode, setHoveredNode] = useState<string | null>(null) const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
function load() { function load() {
Promise.all([ Promise.all([
@@ -151,6 +164,33 @@ function Cockpit() {
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
async function handleLoadModel(name: string) {
try {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Laden des Modells: ${e.message}`)
}
}
async function handleUnloadModel(name: string) {
try {
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Entladen des Modells: ${e.message}`)
}
}
async function handleUnloadAll() {
try {
await api("/api/models/unload", { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Entladen aller Modelle: ${e.message}`)
}
}
async function handleRoleChange(role: string, modelName: string) { async function handleRoleChange(role: string, modelName: string) {
setActiveRoleDrop(null) setActiveRoleDrop(null)
try { try {
@@ -251,9 +291,19 @@ function Cockpit() {
<HardDrive className="h-4.5 w-4.5 text-primary" /> <HardDrive className="h-4.5 w-4.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">VRAM / Memory Belegung</span> <span className="text-xs font-bold uppercase tracking-wider text-foreground">VRAM / Memory Belegung</span>
</div> </div>
<span className="text-[10px] font-mono text-muted-foreground"> <div className="flex items-center gap-3">
Llama Swap VRAM-Pool: {fmtSize(totalRunningSize)} / {fmtSize(capacity)} geladen <span className="text-[10px] font-mono text-muted-foreground">
</span> Llama Swap VRAM-Pool: {fmtSize(totalRunningSize)} / {fmtSize(capacity)} geladen
</span>
{running.length > 0 && (
<button
onClick={handleUnloadAll}
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase transition-all cursor-pointer"
>
Alle entladen
</button>
)}
</div>
</div> </div>
{/* The memory bar */} {/* The memory bar */}
@@ -318,22 +368,34 @@ function Cockpit() {
</defs> </defs>
{/* Bezier Curves: Clients to Gateway (10% -> 50%) */} {/* Bezier Curves: Clients to Gateway (10% -> 50%) */}
{/* Roo Code (y=18) */} {/* Roo Code (y=10) */}
<path d="M 10 18 C 30 18, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" /> <path d="M 10 10 C 30 10, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "roocode" || activeClient === "roocode") && ( {(hoveredNode === "roocode" || activeClient === "roocode") && (
<path d="M 10 18 C 30 18, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" /> <path d="M 10 10 C 30 10, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)} )}
{/* Cursor (y=50) */} {/* Cursor (y=30) */}
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" /> <path d="M 10 30 C 30 30, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "cursor" || activeClient === "cursor") && ( {(hoveredNode === "cursor" || activeClient === "cursor") && (
<path d="M 10 30 C 30 30, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* OpenCode (y=50) */}
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "opencode" || activeClient === "opencode") && (
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" /> <path d="M 10 50 C 30 50, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)} )}
{/* OpenCode (y=82) */} {/* Zed (y=70) */}
<path d="M 10 82 C 30 82, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" /> <path d="M 10 70 C 30 70, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "opencode" || activeClient === "opencode") && ( {(hoveredNode === "zed" || activeClient === "zed") && (
<path d="M 10 82 C 30 82, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" /> <path d="M 10 70 C 30 70, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)}
{/* Continue (y=90) */}
<path d="M 10 90 C 30 90, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "continue" || activeClient === "continue") && (
<path d="M 10 90 C 30 90, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
)} )}
{/* Bezier Curves: Gateway to Roles (50% -> 90%) */} {/* Bezier Curves: Gateway to Roles (50% -> 90%) */}
@@ -371,8 +433,8 @@ function Cockpit() {
{/* HTML Nodes */} {/* HTML Nodes */}
{/* COLUMN 1: Client nodes */} {/* COLUMN 1: Client nodes */}
<div <div
className="absolute cursor-pointer select-none z-10 w-28 h-10 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-xs font-semibold text-foreground shadow shadow-black/20" className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "18%" }} style={{ left: "10%", top: "10%" }}
onMouseEnter={() => setHoveredNode("roocode")} onMouseEnter={() => setHoveredNode("roocode")}
onMouseLeave={() => setHoveredNode(null)} onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "roocode" ? null : "roocode")} onClick={() => setActiveClient(c => c === "roocode" ? null : "roocode")}
@@ -382,8 +444,8 @@ function Cockpit() {
</div> </div>
<div <div
className="absolute cursor-pointer select-none z-10 w-28 h-10 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-xs font-semibold text-foreground shadow shadow-black/20" className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "50%" }} style={{ left: "10%", top: "30%" }}
onMouseEnter={() => setHoveredNode("cursor")} onMouseEnter={() => setHoveredNode("cursor")}
onMouseLeave={() => setHoveredNode(null)} onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "cursor" ? null : "cursor")} onClick={() => setActiveClient(c => c === "cursor" ? null : "cursor")}
@@ -393,8 +455,8 @@ function Cockpit() {
</div> </div>
<div <div
className="absolute cursor-pointer select-none z-10 w-28 h-10 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-xs font-semibold text-foreground shadow shadow-black/20" className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "82%" }} style={{ left: "10%", top: "50%" }}
onMouseEnter={() => setHoveredNode("opencode")} onMouseEnter={() => setHoveredNode("opencode")}
onMouseLeave={() => setHoveredNode(null)} onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "opencode" ? null : "opencode")} onClick={() => setActiveClient(c => c === "opencode" ? null : "opencode")}
@@ -403,9 +465,31 @@ function Cockpit() {
<span>OpenCode</span> <span>OpenCode</span>
</div> </div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "70%" }}
onMouseEnter={() => setHoveredNode("zed")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "zed" ? null : "zed")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Zed</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "90%" }}
onMouseEnter={() => setHoveredNode("continue")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "continue" ? null : "continue")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Continue</span>
</div>
{/* COLUMN 2: Central Gateway Node */} {/* COLUMN 2: Central Gateway Node */}
<div <div
className="absolute select-none z-10 w-36 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5 select-none" className="absolute select-none z-10 w-36 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5"
style={{ left: "50%", top: "50%" }} style={{ left: "50%", top: "50%" }}
> >
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div> <div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div>
@@ -484,7 +568,11 @@ function Cockpit() {
className="absolute z-30 md:w-96 w-[90%] rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl space-y-3 flex flex-col justify-between" className="absolute z-30 md:w-96 w-[90%] rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl space-y-3 flex flex-col justify-between"
style={{ style={{
left: "22%", left: "22%",
top: activeClient === "roocode" ? "8%" : activeClient === "cursor" ? "30%" : "48%", top: activeClient === "roocode" ? "5%"
: activeClient === "cursor" ? "20%"
: activeClient === "opencode" ? "40%"
: activeClient === "zed" ? "55%"
: "65%"
}} }}
> >
{/* Popover Header */} {/* Popover Header */}
@@ -493,6 +581,8 @@ function Cockpit() {
{activeClient === "roocode" && "Roo Code Setup"} {activeClient === "roocode" && "Roo Code Setup"}
{activeClient === "cursor" && "Cursor Setup"} {activeClient === "cursor" && "Cursor Setup"}
{activeClient === "opencode" && "OpenCode Setup"} {activeClient === "opencode" && "OpenCode Setup"}
{activeClient === "zed" && "Zed Setup"}
{activeClient === "continue" && "Continue Setup"}
</span> </span>
<button <button
onClick={() => setActiveClient(null)} onClick={() => setActiveClient(null)}
@@ -521,7 +611,19 @@ function Cockpit() {
{activeClient === "opencode" && ( {activeClient === "opencode" && (
<ul className="list-decimal pl-4 space-y-1"> <ul className="list-decimal pl-4 space-y-1">
<li>Öffne die <code>opencode.jsonc</code> Konfigurationsdatei.</li> <li>Öffne die <code>opencode.jsonc</code> Konfigurationsdatei.</li>
<li>Ersetze den Provider-Eintrag unter <code>providers</code> mit dem Snippet unten.</li> <li>Ersetze den Provider-Eintrag unter <code>provider</code> mit dem Snippet unten.</li>
</ul>
)}
{activeClient === "zed" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne die Zed Settings (<code>ctrl+,</code>).</li>
<li>Füge das untenstehende JSON-Segment unter <code>language_models</code> ein.</li>
</ul>
)}
{activeClient === "continue" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Klicke auf das Zahnrad-Symbol in der Continue-Erweiterung.</li>
<li>Füge den Gateway-Eintrag zum <code>models</code>-Array hinzu.</li>
</ul> </ul>
)} )}
</div> </div>
@@ -533,9 +635,11 @@ function Cockpit() {
<span className="text-[8px] font-mono text-muted-foreground">JSON Config</span> <span className="text-[8px] font-mono text-muted-foreground">JSON Config</span>
<button <button
onClick={() => copySnippet( onClick={() => copySnippet(
activeClient === "roocode" ? connectData.tools.cline.snippet : activeClient === "roocode" ? connectData.tools.cline?.snippet :
activeClient === "cursor" ? connectData.tools.cursor.snippet : activeClient === "cursor" ? connectData.tools.cursor?.snippet :
connectData.tools.opencode.snippet activeClient === "opencode" ? connectData.tools.opencode?.snippet :
activeClient === "zed" ? connectData.tools.zed?.snippet :
connectData.tools.continue?.snippet
)} )}
className="text-[9px] font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer" className="text-[9px] font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer"
> >
@@ -545,9 +649,11 @@ function Cockpit() {
</div> </div>
<pre className="p-3 max-h-36 overflow-y-auto text-[9px] font-mono text-cyan-200/90 whitespace-pre scrollbar-thin select-text"> <pre className="p-3 max-h-36 overflow-y-auto text-[9px] font-mono text-cyan-200/90 whitespace-pre scrollbar-thin select-text">
<code> <code>
{activeClient === "roocode" && connectData.tools.cline.snippet} {activeClient === "roocode" && connectData.tools.cline?.snippet}
{activeClient === "cursor" && connectData.tools.cursor.snippet} {activeClient === "cursor" && connectData.tools.cursor?.snippet}
{activeClient === "opencode" && connectData.tools.opencode.snippet} {activeClient === "opencode" && connectData.tools.opencode?.snippet}
{activeClient === "zed" && connectData.tools.zed?.snippet}
{activeClient === "continue" && connectData.tools.continue?.snippet}
</code> </code>
</pre> </pre>
</div> </div>
@@ -555,109 +661,266 @@ function Cockpit() {
</div> </div>
)} )}
</div> </div>
{/* Legend for the connection graph */}
<div className="flex flex-wrap items-center gap-6 mt-3 text-[10px] text-muted-foreground border-t border-border/20 pt-3">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-cyan-400" />
<span>Cyan-Fluss: Client-Anfrage an Gateway</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span>Grüner Puls: Aktive Verbindung / Warmes Modell geladen</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded bg-gradient-to-r from-teal-500 to-emerald-500" />
<span>VRAM-Verlauf: Modellspezifischer Speicheranteil</span>
</span>
</div>
</div> </div>
{/* ZONE C: Library list cards & Upgrade Radar */} {/* ZONE C: Library list cards & Upgrade Radar */}
<div className="space-y-4"> <div className="space-y-4">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">Installierte Modell-Bibliothek</div> <div className="flex items-center justify-between px-1">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Installierte Modell-Bibliothek</div>
<div className="flex rounded-lg border border-border/40 bg-card/45 p-0.5">
<button
onClick={() => setViewMode("grid")}
className={cn(
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
viewMode === "grid" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Grid
</button>
<button
onClick={() => setViewMode("list")}
className={cn(
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
viewMode === "list" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
List
</button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {viewMode === "grid" ? (
{models.length === 0 ? ( <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="col-span-full text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none"> {models.length === 0 ? (
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen. <div className="col-span-full text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
</div> Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
) : ( </div>
models.map((m) => { ) : (
const isRunning = running.includes(m.name) models.map((m) => {
const hasUpgrade = updates?.model_list.find((u) => u.role === m.role) const isRunning = running.includes(m.name)
const hasUpgrade = updates?.model_list.find((u) => u.role === m.role)
const brand = getBrandInfo(m.name)
return ( return (
<div <div
key={m.name} key={m.name}
className={cn( className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40 group", "rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40 group",
isRunning ? "border-primary/45 shadow-primary/5" : "border-border/60" isRunning ? "border-primary/45 shadow-primary/5" : "border-border/60"
)} )}
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-1"> <div className="flex gap-2.5 min-w-0">
<h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all" title={m.name}> {/* Brand Icon Badge */}
{m.name} <div className={cn("w-7 h-7 rounded-lg border flex items-center justify-center font-bold text-xs shrink-0 shadow-sm", brand.color)} title={brand.name}>
</h3> {brand.initial}
<div className="flex items-center gap-2 flex-wrap"> </div>
<span className="text-[9px] font-mono text-muted-foreground bg-background/40 px-1.5 py-0.5 rounded border border-border/30">
{m.quant || "GGUF"} <div className="min-w-0">
</span> <h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all font-mono" title={m.name}>
{isRunning && ( {m.name.split("/").pop()}
<span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-400 uppercase tracking-wider"> </h3>
<Activity className="h-3 w-3 animate-pulse" /> Warm <div className="flex items-center gap-2 flex-wrap mt-1">
</span> <span className="text-[9px] font-mono text-muted-foreground bg-background/40 px-1.5 py-0.5 rounded border border-border/30">
{m.quant || "GGUF"}
</span>
{isRunning && (
<span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-400 uppercase tracking-wider">
<Activity className="h-3 w-3 animate-pulse" /> Warm
</span>
)}
{m.role && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-mono text-primary font-bold uppercase">
{m.role}
</span>
)}
</div>
</div>
</div>
</div>
<div className="border-t border-border/30 pt-3 flex flex-wrap gap-1">
<CapsChips caps={m.capabilities} />
</div>
</div>
<div className="space-y-3 pt-1">
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono text-muted-foreground">
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
<HardDrive className="h-3.5 w-3.5 text-primary/80" />
<div>
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Größe</div>
<div className="text-foreground font-semibold">{fmtSize(m.size_bytes)}</div>
</div>
</div>
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
<Edit3 className="h-3.5 w-3.5 text-primary/80" />
<div>
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Kontext</div>
<div className="text-foreground font-semibold">{fmtCtx(m.ctx)}</div>
</div>
</div>
</div>
{/* Smart Upgrade radar trigger banner */}
{hasUpgrade && (
<div className="p-3 bg-amber-500/5 border border-amber-500/30 rounded-xl space-y-2 flex flex-col justify-between shrink-0">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping" />
<span>Upgrade verfügbar: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button
onClick={() => handleSmartUpgrade(hasUpgrade.repo, m.role!, m.quant || "Q4_K_M", m.capabilities.tools !== "no")}
className="h-7 w-full flex items-center justify-center gap-1 rounded-lg bg-amber-500 text-black text-[10px] font-bold hover:bg-amber-400 transition-colors cursor-pointer"
>
<Download className="h-3 w-3" /> Smart-Swap starten
</button>
</div>
)}
{/* Actions row at the bottom of Grid Card */}
<div className="flex items-center gap-1.5 border-t border-border/20 pt-3 mt-auto">
<button
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
className={cn(
"flex-1 h-7 rounded-lg text-[9px] font-bold uppercase transition-all cursor-pointer border flex items-center justify-center gap-1",
isRunning
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400"
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary"
)} )}
>
{isRunning ? "Entladen" : "Laden"}
</button>
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
<button
onClick={() => handleDelete(m.name)}
className="h-7 w-7 rounded-lg border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 flex items-center justify-center transition-colors cursor-pointer"
title="Modell löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)
})
)}
</div>
) : (
<div className="space-y-2">
{models.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
</div>
) : (
models.map((m) => {
const isRunning = running.includes(m.name)
const brand = getBrandInfo(m.name)
return (
<div
key={m.name}
className={cn(
"rounded-xl border bg-card/45 backdrop-blur-md p-3.5 shadow-lg shadow-black/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:border-primary/40 transition-all",
isRunning ? "border-primary/45" : "border-border/60"
)}
>
<div className="flex items-center gap-3 min-w-0">
{/* Brand Icon */}
<div className={cn("w-8 h-8 rounded-lg border border-border/40 flex items-center justify-center font-bold text-xs shrink-0 shadow-sm", brand.color)} title={brand.name}>
{brand.initial}
</div>
<div className="min-w-0 text-left">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-xs font-bold text-foreground truncate max-w-[250px] sm:max-w-[400px] break-all font-mono" title={m.name}>
{m.name.split("/").pop()}
</h4>
{m.role && ( {m.role && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-mono text-primary font-bold uppercase"> <span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[8px] font-mono text-primary font-bold uppercase shrink-0">
{m.role} {m.role}
</span> </span>
)} )}
{isRunning && (
<span className="flex items-center gap-0.5 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 mt-0.5">
<span>Größe: {fmtSize(m.size_bytes)}</span>
<span></span>
<span>Kontext: {fmtCtx(m.ctx)}</span>
<span></span>
<span className="font-mono text-[9px]">{m.quant || "GGUF"}</span>
</div> </div>
</div> </div>
<button
onClick={() => handleDelete(m.name)}
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100 shrink-0 cursor-pointer"
title="Modell und GGUF-Dateien löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div> </div>
<div className="border-t border-border/30 pt-3 flex flex-wrap gap-1"> {/* Chips and Actions */}
<CapsChips caps={m.capabilities} /> <div className="flex items-center gap-3 shrink-0 self-end sm:self-auto">
</div> <div className="hidden lg:flex flex-wrap gap-1">
</div> <CapsChips caps={m.capabilities} />
<div className="space-y-3 pt-1">
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono text-muted-foreground">
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
<HardDrive className="h-3.5 w-3.5 text-primary/80" />
<div>
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Größe</div>
<div className="text-foreground font-semibold">{fmtSize(m.size_bytes)}</div>
</div>
</div> </div>
<button <div className="flex items-center gap-1.5">
onClick={() => handleSetCtx(m.name, m.ctx)}
className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20 hover:border-primary/40 text-left transition-colors cursor-pointer"
>
<Edit3 className="h-3.5 w-3.5 text-primary/80" />
<div>
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Kontext</div>
<div className="text-foreground font-semibold">{fmtCtx(m.ctx)}</div>
</div>
</button>
</div>
{/* Smart Upgrade radar trigger banner */}
{hasUpgrade && (
<div className="p-3 bg-amber-500/5 border border-amber-500/30 rounded-xl space-y-2 flex flex-col justify-between shrink-0">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping" />
<span>Upgrade verfügbar: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button <button
onClick={() => handleSmartUpgrade(hasUpgrade.repo, m.role!, m.quant || "Q4_K_M", m.capabilities.tools !== "no")} onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
className="h-7 w-full flex items-center justify-center gap-1 rounded-lg bg-amber-500 text-black text-[10px] font-bold hover:bg-amber-400 transition-colors cursor-pointer" className={cn(
"h-7 px-3 rounded-lg text-[9px] font-bold uppercase transition-all cursor-pointer border flex items-center gap-1",
isRunning
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400"
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary"
)}
> >
<Download className="h-3 w-3" /> Smart-Swap starten {isRunning ? "Entladen" : "Laden"}
</button>
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
<button
onClick={() => handleDelete(m.name)}
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all cursor-pointer"
title="Modell löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
)} </div>
</div> </div>
</div> )
) })
}) )}
)} </div>
</div> )}
</div> </div>
</div> </div>
) )
@@ -791,15 +1054,55 @@ function AddModel() {
) )
} }
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
vision: {
title: "Bilder & Vision",
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
icon: Eye
},
coder: {
title: "Coden & Entwicklung",
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
icon: Code
},
reasoning: {
title: "Logik & Nachdenken",
desc: "Komplexe logische Gedankengänge, Mathematik und tiefgründiges Planen (Reasoning).",
icon: Brain
},
agent: {
title: "Autonomer Agent (Hermes)",
desc: "Führt selbstständig Terminalbefehle aus und interagiert mit deinem Homelab.",
icon: Bot
},
scout: {
title: "Allrounder & Chat",
desc: "Schnelle Antworten, Zusammenfassungen, Übersetzungen und alltägliche Fragen.",
icon: Compass
}
}
function Discover() { function Discover() {
const [data, setData] = useState<DiscoverResp | null>(null) const [data, setData] = useState<DiscoverResp | null>(null)
const [models, setModels] = useState<ModelInfo[]>([])
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState<Record<string, string>>({}) const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [showExpert, setShowExpert] = useState(false)
useEffect(() => { useEffect(() => {
api<DiscoverResp>("/api/discover") Promise.all([
.then(setData) api<DiscoverResp>("/api/discover"),
api<{ models: ModelInfo[] }>("/api/models"),
api<UpdatesResp>("/api/maintenance/updates").catch(() => null)
])
.then(([discoverData, modelsData, updatesData]) => {
setData(discoverData)
setModels(modelsData.models || [])
if (updatesData) setUpdates(updatesData)
})
.catch((e) => setError(String(e))) .catch((e) => setError(String(e)))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
@@ -826,72 +1129,219 @@ function Discover() {
) )
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<AddModel /> {/* Informational Header */}
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-3 rounded-xl"> <div>
Modell-Registry geladen für {data.sys_ram_gb} GB System-RAM markiert die empfohlene Standard-Rolle. Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
</div>
<div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
</div>
</div> </div>
{data.categories.map((cat) => ( {/* Sockets/Slots Grid */}
<div key={cat.role} className="space-y-3"> <div className="grid gap-6 md:grid-cols-2">
<div className="flex items-center gap-2 px-1"> {data.categories.map((cat) => {
<Layers className="h-4 w-4 text-primary" /> const meta = ROLE_METADATA[cat.role] || {
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">{cat.title}</h3> title: cat.title || cat.role,
</div> desc: "Spezifisches Modell für diese Systemrolle.",
icon: Layers
}
const IconComponent = meta.icon
<div className="grid gap-4 sm:grid-cols-2"> // Check if a model is installed for this role
{cat.models.map((m) => { const installedModel = models.find((m) => m.role === cat.role)
const isRec = cat.recommended === m.repo
return ( // Check if an upgrade is available for this role
<div const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
key={m.repo}
className={cn( // Get the primary recommended model
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40", const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
isRec ? "border-primary/30" : "border-border/60" if (!recommendedModel) return null
)}
> const isInstallingRecommended = installing[recommendedModel.repo]
<div className="space-y-3.5"> const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
<div className="flex items-start justify-between gap-3"> const isExpanded = !!expandedAlternatives[cat.role]
<div className="space-y-1">
<div className="flex items-center gap-1.5"> return (
{isRec && <span title="Empfohlen für diese Rolle"><Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400" /></span>} <div
<h4 className="text-xs font-bold text-foreground truncate max-w-[200px]" title={m.name}> key={cat.role}
{m.name} className={cn(
</h4> "rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
</div> installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
<span className="text-[10px] font-mono text-muted-foreground">{m.author}</span> )}
</div> >
<div className="text-[10px] font-mono bg-background/40 px-1.5 py-0.5 rounded border border-border/30 text-muted-foreground"> <div className="space-y-4">
{m.quant} {/* Socket Header */}
</div> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
<IconComponent className="h-5.5 w-5.5" />
</div> </div>
<div>
<div className="flex flex-wrap items-center gap-1.5 border-t border-border/30 pt-3"> <h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
<CapsChips caps={m.caps} /> <span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
<FitBadge fit={m.fit} /> Rolle: {cat.role}
</span>
</div> </div>
</div> </div>
{/* Status Badges */}
{installedModel ? (
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aktiviert
</span>
) : (
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
Frei
</span>
)}
</div>
{m.fit.level !== "too_tight" && ( {/* Role Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{meta.desc}
</p>
{/* Current vs Recommended model card */}
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
{installedModel ? (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
{installedModel.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
<span></span>
<span>Quant: {installedModel.quant || "GGUF"}</span>
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
{recommendedModel.name}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
<span>Ersteller: {recommendedModel.author}</span>
<span></span>
<span>Quant: {recommendedModel.quant}</span>
</div>
<div className="flex items-center gap-1.5 pt-0.5">
<FitBadge fit={recommendedModel.fit} />
</div>
</div>
)}
</div>
{/* Main Action Button */}
<div className="pt-1">
{installedModel ? (
hasUpgrade ? (
<div className="space-y-2">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!installing[hasUpgrade.repo]}
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
>
<Download className="h-3.5 w-3.5" />
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
</button>
</div>
) : (
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
<Check className="h-4 w-4" /> Auf neuestem Stand
</div>
)
) : (
<button <button
onClick={() => install(m.repo, m.role, m.quant, m.caps.tools !== "no")} onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!installing[m.repo]} disabled={!!isInstallingRecommended}
className={cn( className={cn(
"mt-2 h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer border border-border/60 bg-background/20 hover:border-primary/50 disabled:opacity-50", "h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
installing[m.repo] && "border-primary/40 bg-primary/5 text-primary" isInstallingRecommended
? "border-primary/40 bg-primary/5 text-primary"
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
)} )}
> >
<Download className={cn("h-3.5 w-3.5", !installing[m.repo] && "text-primary")} /> <Download className="h-3.5 w-3.5" />
{installing[m.repo] || "Modell laden"} {isInstallingRecommended || "Optimales Modell einsetzen"}
</button> </button>
)} )}
</div> </div>
) </div>
})}
{/* Collapsible alternatives list */}
{alternativeModels.length > 0 && (
<div className="border-t border-border/20 pt-3">
<button
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
</button>
{isExpanded && (
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
{alternativeModels.map((alt) => (
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
{alt.name}
</div>
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
<span>Quant: {alt.quant}</span>
<span></span>
<span>{alt.fit.text}</span>
</div>
</div>
<button
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
disabled={!!installing[alt.repo]}
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
>
{installing[alt.repo] || "Installieren"}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Collapsible Custom Hugging Face Downloader */}
<div className="border border-border/50 bg-card/20 rounded-2xl overflow-hidden shadow-md mt-4">
<button
onClick={() => setShowExpert(!showExpert)}
className="w-full px-5 py-4 flex items-center justify-between text-xs font-bold uppercase tracking-wider text-muted-foreground hover:bg-card/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
<span>Eigenes Modell von Hugging Face laden (Erweiterte Ansicht)</span>
</div> </div>
</div> <span className="text-[10px] text-primary hover:underline">
))} {showExpert ? "Ausblenden ▲" : "Anzeigen ▼"}
</span>
</button>
{showExpert && (
<div className="p-5 border-t border-border/20 bg-card/10">
<AddModel />
</div>
)}
</div>
</div> </div>
) )
} }
+11 -113
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity, ShieldAlert } from "lucide-react" import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api, type ServicesResp, type SystemStatus, type UpdatesResp } from "@/lib/api" import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
function gb(b: number) { function gb(b: number) {
@@ -36,115 +36,6 @@ function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string;
) )
} }
function MaintenanceSection() {
const [u, setU] = useState<UpdatesResp | null>(null)
const [msg, setMsg] = useState("")
const [loading, setLoading] = useState(false)
function load() {
api<UpdatesResp>("/api/maintenance/updates").then(setU).catch(() => {})
}
useEffect(load, [])
async function postAction(path: string, label: string) {
setMsg(`${label} wird ausgeführt...`)
setLoading(true)
try {
const r = await api<{ job_id?: string; ok?: boolean; err?: string }>(path, { method: "POST" })
setMsg(r.job_id ? `${label} gestartet (Job-ID: ${r.job_id})` : r.ok ? `${label} erfolgreich ausgeführt.` : `Fehler: ${r.err || "Unbekannt"}`)
} catch (e: any) {
setMsg(`Fehler bei ${label}: ${e.message}`)
} finally {
setLoading(false)
}
}
async function upgradeModel(repo: string, role: string) {
setMsg(`Upgrade für ${repo} wird gestartet...`)
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
})
setMsg(`Upgrade-Download gestartet.`)
} catch (e: any) {
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartung &amp; Updates</div>
{u && (
<div className="flex gap-1.5 text-[9px] font-mono font-bold uppercase tracking-wider">
<span className={cn("px-1.5 py-0.5 rounded", u.os ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" : "bg-background/40 text-muted-foreground border border-border/20")}>
OS: {u.os}
</span>
<span className={cn("px-1.5 py-0.5 rounded", u.engine ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" : "bg-background/40 text-muted-foreground border border-border/20")}>
Engine: {u.engine ? "neu" : "aktuell"}
</span>
<span className={cn("px-1.5 py-0.5 rounded", u.models ? "bg-primary/10 text-primary border border-primary/20" : "bg-background/40 text-muted-foreground border border-border/20")}>
Modelle: {u.models}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
disabled={loading}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50"
>
OS (Apt) aktualisieren
</button>
<button
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
disabled={loading}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50"
>
Engine aktualisieren
</button>
<button
onClick={() => { if (confirm("Bist du sicher, dass du den Host neu starten willst?")) postAction("/api/maintenance/reboot", "Reboot") }}
disabled={loading}
className="h-8 px-3 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-xs font-semibold hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
Host Reboot
</button>
</div>
{u && u.model_list.length > 0 && (
<div className="mt-3 space-y-2 border-t border-border/20 pt-3">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Updates:</div>
<div className="space-y-1.5">
{u.model_list.map((m) => (
<div key={m.repo} className="flex items-center justify-between p-2.5 rounded-xl bg-background/20 border border-border/30 text-xs font-semibold">
<span className="truncate"><span className="text-primary uppercase font-mono text-[10px] mr-1.5">{m.role}</span> {m.repo}</span>
<button
onClick={() => upgradeModel(m.repo, m.role)}
className="px-2.5 py-1 text-[10px] font-bold rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer"
>
Laden
</button>
</div>
))}
</div>
</div>
)}
{msg && <div className="text-[10px] font-mono text-primary font-medium">{msg}</div>}
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2 flex items-center gap-1">
<ShieldAlert className="h-3.5 w-3.5 shrink-0" />
<span>OS-Update & Reboot benötigen NOPASSWD Berechtigungen in der sudoers Datei des Hosts.</span>
</div>
</div>
)
}
export function SystemView() { export function SystemView() {
const [s, setS] = useState<SystemStatus | null>(null) const [s, setS] = useState<SystemStatus | null>(null)
const [svc, setSvc] = useState<ServicesResp | null>(null) const [svc, setSvc] = useState<ServicesResp | null>(null)
@@ -259,7 +150,15 @@ export function SystemView() {
{/* Services Health matrix */} {/* Services Health matrix */}
{svc && ( {svc && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Homelab-Dienste</div> <div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Homelab-Dienste</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
className="h-7 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
>
System-Logs anzeigen
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{svc.services.map((x) => ( {svc.services.map((x) => (
@@ -336,7 +235,6 @@ export function SystemView() {
)} )}
{/* Maintenance controls inside system view */} {/* Maintenance controls inside system view */}
<MaintenanceSection />
</div> </div>
) )
} }