feat: simplify Discover view into socket cards & backend upgrades
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-330
File diff suppressed because one or more lines are too long
+350
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -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 |
Vendored
+3
-2
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -31,6 +32,12 @@ export function SystemDrawer({ open, onClose }: SystemDrawerProps) {
|
|||||||
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)
|
||||||
|
|
||||||
function loadUpdates() {
|
function loadUpdates() {
|
||||||
|
|||||||
@@ -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: > {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 & 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>
|
||||||
|
|||||||
@@ -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 & Pflege</h2>
|
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates & 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">
|
||||||
|
|||||||
+613
-163
@@ -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>
|
||||||
|
|
||||||
{m.fit.level !== "too_tight" && (
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user