feat(2.0): W8 — Wartung (OS/Engine/Modell-Updates, Restart, Reboot, Logs)
services/maintenance.py + routers/maintenance.py: updates-Badge (apt/Engine- Release/dyn. Modell-Upgrades via discover), os-update + engine-update als jobengine-Jobs, system-/user-aware Restart (llama-swap via sudo -n NOPASSWD), reboot, logs (journalctl). Frontend: Wartungs-Block in SystemView mit Badge, Buttons + Modell-Upgrade-Vorschlaegen. Passwortfrei (sudo -n). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from config import FRONTEND_DIST, VERSION
|
from config import FRONTEND_DIST, VERSION
|
||||||
from routers import agent, connect, gateway_proxy, health, memory, models, routing, system
|
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system
|
||||||
|
|
||||||
app = FastAPI(title="Mission Control 2.0", version=VERSION)
|
app = FastAPI(title="Mission Control 2.0", version=VERSION)
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ app.include_router(connect.router)
|
|||||||
app.include_router(memory.router)
|
app.include_router(memory.router)
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||||
|
app.include_router(maintenance.router)
|
||||||
|
|
||||||
|
|
||||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ PORT = int(os.environ.get("MC_PORT", "9000"))
|
|||||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||||
|
|
||||||
# Version (Phase 0 — Greenfield-Skeleton).
|
# Version (Phase 0 — Greenfield-Skeleton).
|
||||||
VERSION = "2.0.0-w1"
|
VERSION = "2.0.0-w8"
|
||||||
|
|
||||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||||
yaml = YAML()
|
yaml = YAML()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from services import maintenance
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maintenance/updates")
|
||||||
|
def updates() -> dict:
|
||||||
|
return maintenance.updates()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/os-update")
|
||||||
|
def os_update() -> dict:
|
||||||
|
return {"job_id": maintenance.os_update_job()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/engine-update")
|
||||||
|
def engine_update() -> dict:
|
||||||
|
job = maintenance.engine_update_job()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||||
|
return {"job_id": job}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/reboot")
|
||||||
|
def reboot() -> dict:
|
||||||
|
return maintenance.reboot()
|
||||||
|
|
||||||
|
|
||||||
|
class RestartReq(BaseModel):
|
||||||
|
service: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/restart")
|
||||||
|
def restart(body: RestartReq) -> dict:
|
||||||
|
return maintenance.restart_service(body.service)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maintenance/logs")
|
||||||
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
|
return maintenance.logs(service, lines)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware),
|
||||||
|
Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py).
|
||||||
|
|
||||||
|
Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig
|
||||||
|
erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from services import discover, jobengine, llamaswap
|
||||||
|
|
||||||
|
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||||
|
SYSTEM_SERVICES = {"llama-swap"}
|
||||||
|
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-dashboard", "hermes-webui", "litellm-gateway"}
|
||||||
|
|
||||||
|
ENGINE_UPDATE_CMD = os.environ.get("MC_ENGINE_UPDATE_CMD", "")
|
||||||
|
ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
||||||
|
_engine_cache = {"ts": 0.0, "avail": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _ram_gb() -> float:
|
||||||
|
return psutil.virtual_memory().total / (1024 ** 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _os_upgradable() -> int:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["bash", "-c", "apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
return int((out.stdout or "0").strip() or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_update_available() -> bool:
|
||||||
|
now = time.time()
|
||||||
|
if now - _engine_cache["ts"] < 3600:
|
||||||
|
return _engine_cache["avail"]
|
||||||
|
avail = False
|
||||||
|
try:
|
||||||
|
rel = httpx.get("https://api.github.com/repos/lemonade-sdk/llamacpp-rocm/releases/latest",
|
||||||
|
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
|
||||||
|
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
|
||||||
|
inst = os.path.getmtime(ENGINE_PATH)
|
||||||
|
avail = pub > inst + 86400
|
||||||
|
except Exception:
|
||||||
|
avail = False
|
||||||
|
_engine_cache.update(ts=now, avail=avail)
|
||||||
|
return avail
|
||||||
|
|
||||||
|
|
||||||
|
def model_upgrades() -> list[dict]:
|
||||||
|
"""Dynamisch: je discover-Kategorie das empfohlene Modell, das NOCH NICHT installiert ist
|
||||||
|
→ Upgrade-Vorschlag für diese Rolle. Self-updating (kein Hardcode wie v1)."""
|
||||||
|
disc = discover.safe_discover(_ram_gb())
|
||||||
|
if not disc:
|
||||||
|
return []
|
||||||
|
cmds = " ".join(str(s.get("cmd", "")).lower()
|
||||||
|
for s in (llamaswap.read_config().get("models") or {}).values())
|
||||||
|
out = []
|
||||||
|
for c in disc.get("categories", []):
|
||||||
|
rec = c.get("recommended")
|
||||||
|
if not rec:
|
||||||
|
continue
|
||||||
|
base = rec.split("/")[-1].lower()
|
||||||
|
stem = base[:-5] if base.endswith("-gguf") else base
|
||||||
|
if base in cmds or (stem and stem in cmds):
|
||||||
|
continue
|
||||||
|
out.append({"role": c["role"], "title": c["title"], "repo": rec})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def updates() -> dict:
|
||||||
|
ups = model_upgrades()
|
||||||
|
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
|
||||||
|
"models": len(ups), "model_list": ups}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str]) -> dict:
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
|
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"ok": False, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def restart_service(name: str) -> dict:
|
||||||
|
if name in SYSTEM_SERVICES:
|
||||||
|
return _run(["sudo", "-n", "systemctl", "restart", name])
|
||||||
|
if name in USER_SERVICES:
|
||||||
|
return _run(["systemctl", "--user", "restart", name])
|
||||||
|
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
||||||
|
|
||||||
|
|
||||||
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
|
lines = max(1, min(lines, 1000))
|
||||||
|
if service in SYSTEM_SERVICES:
|
||||||
|
cmd = ["sudo", "-n", "journalctl", "-u", service, "-n", str(lines), "--no-pager"]
|
||||||
|
elif service in USER_SERVICES:
|
||||||
|
cmd = ["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]
|
||||||
|
else:
|
||||||
|
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
||||||
|
r = _run(cmd)
|
||||||
|
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
||||||
|
|
||||||
|
|
||||||
|
def os_update_job() -> str:
|
||||||
|
cmd = "sudo -n apt-get update && sudo -n DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
|
||||||
|
return jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)")
|
||||||
|
|
||||||
|
|
||||||
|
def engine_update_job() -> str | None:
|
||||||
|
if not ENGINE_UPDATE_CMD:
|
||||||
|
return None
|
||||||
|
cmd = f"{ENGINE_UPDATE_CMD} && sudo -n systemctl restart llama-swap"
|
||||||
|
return jobengine.start_job(["bash", "-c", cmd], "Engine-Update (llama.cpp)")
|
||||||
|
|
||||||
|
|
||||||
|
def reboot() -> dict:
|
||||||
|
return _run(["sudo", "-n", "reboot"])
|
||||||
-185
File diff suppressed because one or more lines are too long
+190
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="theme-color" content="#0d1117" />
|
<meta name="theme-color" content="#0d1117" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DP8A83Cd.js"></script>
|
<script type="module" crossorigin src="/assets/index-S7P9XrdI.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-IpcnHTkp.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-IpcnHTkp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ export interface ServicesResp {
|
|||||||
links: { engine_ui: string; gateway: string; hermes_webui: string }
|
links: { engine_ui: string; gateway: string; hermes_webui: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatesResp {
|
||||||
|
os: number
|
||||||
|
engine: number
|
||||||
|
models: number
|
||||||
|
model_list: { role: string; title: string; repo: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectTool {
|
export interface ConnectTool {
|
||||||
label: string
|
label: string
|
||||||
lang: string
|
lang: string
|
||||||
|
|||||||
@@ -1,12 +1,90 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { ExternalLink, Save } from "lucide-react"
|
import { ExternalLink, RefreshCw, Save } from "lucide-react"
|
||||||
import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
|
import { api, type ServicesResp, type SystemStatus, type UpdatesResp } from "@/lib/api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function gb(b: number) {
|
function gb(b: number) {
|
||||||
return (b / 1024 ** 3).toFixed(1)
|
return (b / 1024 ** 3).toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Maintenance() {
|
||||||
|
const [u, setU] = useState<UpdatesResp | null>(null)
|
||||||
|
const [msg, setMsg] = useState("")
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
api<UpdatesResp>("/api/maintenance/updates").then(setU).catch(() => {})
|
||||||
|
}
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
async function post(path: string, label: string) {
|
||||||
|
setMsg(`${label}…`)
|
||||||
|
try {
|
||||||
|
const r = await api<{ job_id?: string; ok?: boolean; err?: string }>(path, { method: "POST" })
|
||||||
|
setMsg(r.job_id ? `${label} gestartet (Job ${r.job_id})` : r.ok ? `${label} ✓` : `${label}: ${r.err || "?"}`)
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(`${label}: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function restart(service: string) {
|
||||||
|
setMsg(`Restart ${service}…`)
|
||||||
|
try {
|
||||||
|
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||||
|
method: "POST", body: JSON.stringify({ service }),
|
||||||
|
})
|
||||||
|
setMsg(r.ok ? `Restart ${service} ✓` : `Restart ${service}: ${r.err || "?"}`)
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(`Restart ${service}: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function upgrade(repo: string, role: string) {
|
||||||
|
setMsg(`Lade ${repo}…`)
|
||||||
|
try {
|
||||||
|
await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true }) })
|
||||||
|
setMsg(`Upgrade ${repo} gestartet`)
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(`Fehler: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Wartung & Updates</span>
|
||||||
|
{u && (
|
||||||
|
<span className="flex gap-2 text-xs">
|
||||||
|
<span className={cn("rounded px-1.5 py-0.5", u.os ? "bg-amber-500/15 text-amber-500" : "bg-muted text-muted-foreground")}>OS: {u.os}</span>
|
||||||
|
<span className={cn("rounded px-1.5 py-0.5", u.engine ? "bg-amber-500/15 text-amber-500" : "bg-muted text-muted-foreground")}>Engine: {u.engine ? "neu" : "aktuell"}</span>
|
||||||
|
<span className={cn("rounded px-1.5 py-0.5", u.models ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>Modelle: {u.models}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
|
<button onClick={() => post("/api/maintenance/os-update", "OS-Update")} className="rounded-md border border-border px-2.5 py-1.5 hover:bg-accent">OS aktualisieren</button>
|
||||||
|
<button onClick={() => post("/api/maintenance/engine-update", "Engine-Update")} className="rounded-md border border-border px-2.5 py-1.5 hover:bg-accent">Engine aktualisieren</button>
|
||||||
|
<button onClick={() => restart("llama-swap")} className="flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 hover:bg-accent"><RefreshCw className="h-3.5 w-3.5" /> Engine neu starten</button>
|
||||||
|
<button onClick={() => { if (confirm("Box wirklich neu starten?")) post("/api/maintenance/reboot", "Reboot") }} className="rounded-md border border-border px-2.5 py-1.5 text-amber-500 hover:bg-accent">Reboot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{u && u.model_list.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">Modell-Upgrades verfügbar:</div>
|
||||||
|
{u.model_list.map((m) => (
|
||||||
|
<div key={m.repo} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="truncate"><span className="text-primary">{m.role}</span> · {m.repo}</span>
|
||||||
|
<button onClick={() => upgrade(m.repo, m.role)} className="rounded-md border border-border px-2 py-0.5 hover:bg-accent">Upgrade</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <div className="mt-2 text-xs text-muted-foreground">{msg}</div>}
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||||
|
OS-Update/Reboot brauchen einmalig erweiterte NOPASSWD-sudoers (siehe BEDIENUNG/CUTOVER).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Bar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
|
function Bar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card p-4">
|
<div className="rounded-lg border border-border bg-card p-4">
|
||||||
@@ -126,6 +204,8 @@ export function SystemView() {
|
|||||||
</button>
|
</button>
|
||||||
{backupMsg && <span className="text-xs text-muted-foreground">{backupMsg}</span>}
|
{backupMsg && <span className="text-xs text-muted-foreground">{backupMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Maintenance />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user