feat(2.0): Phase 5 — Betrieb, Observability + Politur

Backup: services/backup.py (Memory-SQLite inkl. WAL/SHM + Configs, retain 7)
+ deploy/backup.sh + POST /api/system/backup + GET /api/system/backups.
Services-Health: GET /api/system/services (aggregierte Reachability +
Observability-Links). Frontend: SystemView mit Dienste-Health + Backup-
Button + Links; Theme-Toggle (Hell/Dunkel, persistent).

Lokal verifiziert: Backup-Snapshot + services-Aggregat (TestClient),
Frontend-Build + Browser (Light-Mode bestaetigt via computed styles).
Damit Phasen 0-5 lokal fertig. Docs aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 08:29:27 +02:00
parent c773dd7eae
commit 1f4c987652
13 changed files with 239 additions and 45 deletions
+5 -1
View File
@@ -7,7 +7,7 @@ siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phase 4 (Hermes-Schicht — MC-Seite) ✅ · Box-Ausführung offen
## Status: Phase 5 ✅ (Phasen 05 lokal fertig) · Box-Deploy/-Wiring + Cutover offen
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
@@ -25,8 +25,12 @@ Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
`deploy/hermes-webui.service`, **Box-Runbook** [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md)
(hermes-webui, Brain=`auto`, Tools/MCP-Verdrahtung). Box-Ausführung steht noch aus.
- **Phase 5** — **Backup** (Memory + Configs), **Services-Health** + Observability-Links,
**Theme-Toggle** (Hell/Dunkel). Box-Deploy/-Wiring + Cutover (Phase 6) brauchen die Box.
API: `health · models · discover · fit · models/register · groups · routing · system/* · connect ·
memory/* · agent/status` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`).
Box-Runbooks: `docs/HERMES_SETUP.md` + `deploy/` (Units, deploy.sh, backup.sh).
## Entwickeln
+1 -1
View File
@@ -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")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-phase4"
VERSION = "2.0.0-phase5"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
+34
View File
@@ -11,6 +11,11 @@ import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import GATEWAY_URL, HERMES_API_URL, HERMES_WEBUI_URL, LLAMA_SWAP_URL
from services import backup as backup_svc
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable
from services.system import system_status
router = APIRouter(prefix="/api")
@@ -26,6 +31,35 @@ def status() -> dict:
return system_status()
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status()
return {
"services": [
{"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (LiteLLM)", "url": GATEWAY_URL, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-WebUI", "url": HERMES_WEBUI_URL, "ok": a["webui_reachable"]},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": GATEWAY_URL,
"hermes_webui": HERMES_WEBUI_URL,
},
}
@router.post("/system/backup")
def backup() -> dict:
return backup_svc.backup_now()
@router.get("/system/backups")
def backups() -> dict:
return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
+51
View File
@@ -0,0 +1,51 @@
"""
Backup der „Verfassung" (Shared-Memory-SQLite) + aller Configs.
Snapshot in einen Zeitstempel-Ordner; die letzten N bleiben erhalten.
"""
import shutil
import time
from pathlib import Path
from config import CONFIG_PATH, GATEWAY_CONFIG_PATH, MEMORY_DB, MODELS_DIR
BACKUP_DIR = Path(MODELS_DIR) / "mc2-backups"
RETAIN = 7
def _safe_copy(src: Path, dst_dir: Path) -> str | None:
try:
if src and src.exists():
shutil.copy2(src, dst_dir / src.name)
return src.name
except Exception:
pass
return None
def backup_now() -> dict:
"""Erstellt einen Snapshot (Memory-DB inkl. WAL/SHM + Configs). Alte Snapshots
über RETAIN hinaus werden entfernt."""
ts = time.strftime("%Y%m%d-%H%M%S")
dst = BACKUP_DIR / ts
dst.mkdir(parents=True, exist_ok=True)
saved = []
for src in (MEMORY_DB, Path(str(MEMORY_DB) + "-wal"), Path(str(MEMORY_DB) + "-shm"),
CONFIG_PATH, GATEWAY_CONFIG_PATH):
if (name := _safe_copy(src, dst)):
saved.append(name)
# Aufräumen: nur die letzten RETAIN Snapshots behalten.
snaps = sorted([p for p in BACKUP_DIR.iterdir() if p.is_dir()], reverse=True)
for old in snaps[RETAIN:]:
shutil.rmtree(old, ignore_errors=True)
return {"ok": bool(saved), "snapshot": ts, "files": saved, "dir": str(dst)}
def list_backups() -> list[dict]:
if not BACKUP_DIR.exists():
return []
out = []
for p in sorted([d for d in BACKUP_DIR.iterdir() if d.is_dir()], reverse=True):
files = [f.name for f in p.iterdir() if f.is_file()]
out.append({"snapshot": p.name, "files": files})
return out
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Backup der Shared-Memory-SQLite + Configs (cron-bar auf der Box).
# Behält die letzten 7 Snapshots. Identisch zur /api/system/backup-Logik.
set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
MEM_DB="${MC_MEMORY_DB:-$MODELS_DIR/mc2-memory.db}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
GW="${MC_GATEWAY_CONFIG:-/opt/mission-control-2/gateway/config.yaml}"
DEST="$MODELS_DIR/mc2-backups/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DEST"
for f in "$MEM_DB" "$MEM_DB-wal" "$MEM_DB-shm" "$LSWAP" "$GW"; do
[ -f "$f" ] && cp -p "$f" "$DEST/" || true
done
# nur die letzten 7 behalten
ls -1dt "$MODELS_DIR"/mc2-backups/*/ 2>/dev/null | tail -n +8 | xargs -r rm -rf
echo "OK — Backup unter $DEST"
+3 -1
View File
@@ -24,7 +24,9 @@
- [~] **Phase 4 — Hermes-Schicht:** MC-Seite ✅ (services/agent.py + /api/agent/status, AgentView mit
Status-Tiles + „Hermes öffnen"), `deploy/hermes-webui.service`, **Runbook `docs/HERMES_SETUP.md`**.
**Box-Ausführung offen** (hermes-webui installieren, Brain=auto, Tools/MCP verdrahten — Runbook).
- [ ] **Phase 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health).
- [x] **Phase 5 — Betrieb/Observability/Politur:** Backup (Memory-SQLite + Configs, retain 7;
`/api/system/backup` + `deploy/backup.sh`), Services-Health (`/api/system/services`), Observability-
Links (llama-swap /ui, Gateway), **Theme-Toggle Hell/Dunkel**. Lokal verifiziert.
- [ ] **Phase 6 — Cutover** (/opt → v2, v1 aus). **Braucht Box.**
- Offen aus Phase 0/1: **Box-Deploy** (Gitea-Repo da; Box-Setup als systemd-USER-Dienst noch offen),
LiteLLM **Complexity-Auto-Router** gegen installierte Version verifizieren.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-umMcKqw6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nVsgoG2p.css">
<script type="module" crossorigin src="/assets/index-BYe3GDjZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CatWSvf4.css">
</head>
<body>
<div id="root"></div>
+16 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
import { Command as CommandIcon } from "lucide-react"
import { Command as CommandIcon, Moon, Sun } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ModelsView } from "@/views/ModelsView"
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"
export default function App() {
const [view, setView] = useState<ViewId>("models")
const [health, setHealth] = useState<Health | null>(null)
const [dark, setDark] = useState(() => localStorage.getItem("mc_theme") !== "light")
useEffect(() => {
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null))
@@ -23,6 +24,11 @@ export default function App() {
return () => clearInterval(t)
}, [])
useEffect(() => {
document.documentElement.classList.toggle("dark", dark)
localStorage.setItem("mc_theme", dark ? "dark" : "light")
}, [dark])
const active = NAV.find((n) => n.id === view)!
return (
@@ -78,6 +84,14 @@ export default function App() {
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-6">
<div className="text-sm text-muted-foreground">{active.hint}</div>
<div className="flex items-center gap-2">
<button
onClick={() => setDark((d) => !d)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-accent"
title="Hell/Dunkel"
>
{dark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
@@ -89,6 +103,7 @@ export default function App() {
<span>Springen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">K</kbd>
</button>
</div>
</header>
<main className="flex-1 overflow-y-auto p-6">
+5
View File
@@ -88,6 +88,11 @@ export interface SystemStatus {
disk: { total: number; used: number; percent: number } | null
}
export interface ServicesResp {
services: { name: string; url: string; ok: boolean }[]
links: { engine_ui: string; gateway: string; hermes_webui: string }
}
export interface ConnectTool {
label: string
lang: string
+51 -2
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react"
import { api, type SystemStatus } from "@/lib/api"
import { ExternalLink, Save } from "lucide-react"
import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
import { cn } from "@/lib/utils"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
@@ -22,15 +24,30 @@ function Bar({ label, percent, detail }: { label: string; percent: number; detai
export function SystemView() {
const [s, setS] = useState<SystemStatus | null>(null)
const [svc, setSvc] = useState<ServicesResp | null>(null)
const [error, setError] = useState("")
const [backupMsg, setBackupMsg] = useState("")
useEffect(() => {
const load = () => api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
const load = () => {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
}
load()
const t = setInterval(load, 3000)
return () => clearInterval(t)
}, [])
async function doBackup() {
setBackupMsg("…")
try {
const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" })
setBackupMsg(r.ok ? `Snapshot ${r.snapshot} (${r.files.length} Dateien)` : "Nichts zu sichern")
} catch (e) {
setBackupMsg(`Fehler: ${e}`)
}
}
return (
<div className="space-y-4">
<div>
@@ -75,6 +92,38 @@ export function SystemView() {
{s.temp.gpu != null && <span>GPU {s.temp.gpu} °C</span>}
</div>
)}
{/* Dienste-Health + Observability-Links */}
{svc && (
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium">Dienste</div>
<div className="grid gap-2 sm:grid-cols-2">
{svc.services.map((x) => (
<div key={x.name} className="flex items-center gap-2 text-sm">
<span className={cn("h-2 w-2 rounded-full", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
<span>{x.name}</span>
<span className="ml-auto font-mono text-xs text-muted-foreground">{x.url}</span>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-xs">
<a href={svc.links.engine_ui} target="_blank" rel="noopener" className="flex items-center gap-1 text-primary hover:underline">
<ExternalLink className="h-3 w-3" /> Engine-Logs (llama-swap /ui)
</a>
<a href={svc.links.gateway} target="_blank" rel="noopener" className="flex items-center gap-1 text-primary hover:underline">
<ExternalLink className="h-3 w-3" /> Gateway
</a>
</div>
</div>
)}
{/* Backup */}
<div className="flex items-center gap-3">
<button onClick={doBackup} className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent">
<Save className="h-3.5 w-3.5 text-primary" /> Backup jetzt
</button>
{backupMsg && <span className="text-xs text-muted-foreground">{backupMsg}</span>}
</div>
</div>
)
}