Hermes-Terminal same-origin + Passwort-Login; Engine/OS-Update-Fixes

Terminals (wie die Box-Konsole):
- hermes-terminal bindet jetzt NUR Loopback (--interface lo --base-path /hermes-terminal)
  und wird von MC2 same-origin durchgereicht (routers/console.py generalisiert auf beide
  ttyd-Instanzen). Kein eigener Firewall-Port mehr noetig.
- Beide Terminals starten die Shell/CLI ueber `su - hitonabi` → fragen beim Oeffnen das
  Box-Passwort ab (PAM gegen das echte Konto, nichts gespeichert). „Login mit sudo-PW".
- agent_status.terminal_url = /hermes-terminal/ (+ reachable via Loopback-Check).

Engine-Update (llama.cpp) — Fix „nicht moeglich":
- update-engine.sh/update-swap.sh sind per sudoers NOPASSWD freigegeben → das fruehere
  `sudo true`-Passwort-Gate hat sie faelschlich blockiert (wenn kein/falsches Box-PW). Gate
  entfernt → Engine-/Router-Update laufen jetzt passwortlos.

OS-Update (apt) — Fix „nicht moeglich":
- DEBIAN_FRONTEND wird jetzt INNERHALB `sudo bash -c '…'` gesetzt statt `sudo VAR=… cmd`
  (sonst lehnt sudos env-Policy die Variable ab und das Upgrade bricht ab).
- Frontend verschluckt password_required/incorrect_password nicht mehr still, sondern zeigt
  einen klaren Hinweis (Box-Passwort in „Box-Zugang" setzen/pruefen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-04 21:18:55 +02:00
parent 00fc7d6d81
commit eff23643e6
11 changed files with 215 additions and 185 deletions
+5 -3
View File
@@ -94,9 +94,11 @@ HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) --------------- # --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen. # Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/") VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI (Ersatz für AnythingLLM-Chat). # Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI. Wie die Box-Konsole bindet
# Wird in MC2 per iframe eingebettet (Terminal-Seite). Siehe deploy/hermes-terminal.service. # es NUR an Loopback (127.0.0.1:7681, base-path /hermes-terminal) und wird von MC2 same-origin
HERMES_TERMINAL_URL = os.environ.get("MC_HERMES_TERMINAL_URL", "http://192.168.178.151:7681").rstrip("/") # durchgereicht (routers/console.py → /hermes-terminal/). Login per `su` (Box-Passwort).
HERMES_TERMINAL_UPSTREAM = os.environ.get("MC_HERMES_TERMINAL_UPSTREAM", "http://127.0.0.1:7681").rstrip("/")
HERMES_TERMINAL_PATH = "/hermes-terminal/"
# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback # Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001 # (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole # rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
+66 -65
View File
@@ -1,13 +1,12 @@
""" """
Box-Konsole-Reverse-Proxy. ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
Der ttyd-Login-Shell-Dienst (deploy/box-console.service) bindet NUR an Loopback Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
(127.0.0.1:7682, --base-path /console). Dieser Router reicht ihn über den ohnehin den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
offenen MC2-Port (9001) durch — HTTP (index.html/token) + WebSocket (/console/ws). + WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
Vorteil: die Konsole braucht KEINE eigene Firewall-Freigabe (Port 7682 bleibt von wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
außen dicht) und läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
`tty`-Subprotokoll wird auf beiden Seiten ausgehandelt.
""" """
import asyncio import asyncio
@@ -18,70 +17,72 @@ import websockets
from fastapi import APIRouter, Request, WebSocket from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response from starlette.responses import Response
from config import BOX_CONSOLE_UPSTREAM from config import BOX_CONSOLE_UPSTREAM, HERMES_TERMINAL_UPSTREAM
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_WS_UPSTREAM = BOX_CONSOLE_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://")
def _register(base: str, upstream: str) -> None:
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket("/console/ws") @router.websocket(f"/{base}/ws")
async def console_ws(ws: WebSocket) -> None: async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
"""Browser-WS ⇄ lokaler ttyd-WS. ttyd verlangt das `tty`-Subprotokoll auf beiden Seiten.""" await ws.accept(subprotocol="tty")
await ws.accept(subprotocol="tty")
try:
async with websockets.connect(
f"{_WS_UPSTREAM}/console/ws",
subprotocols=["tty"],
open_timeout=10,
max_size=None,
ping_interval=None,
) as up:
async def client_to_upstream() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def upstream_to_client() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
done, pending = await asyncio.wait(
[asyncio.create_task(client_to_upstream()), asyncio.create_task(upstream_to_client())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("console_ws: Verbindung beendet/fehlgeschlagen", exc_info=True)
finally:
try: try:
await ws.close() async with websockets.connect(
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception: except Exception:
pass log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
async def _proxy_http(request: Request, path: str = "") -> Response:
url = f"{upstream}/{base}/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, url, content=await request.body())
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
name=f"{base}_path")
@router.api_route("/console", methods=["GET"]) # Box-Konsole (Login-Shell) und Hermes-Terminal (Agent-CLI) — beide Loopback-ttyd.
@router.api_route("/console/{path:path}", methods=["GET", "POST"]) _register("console", BOX_CONSOLE_UPSTREAM)
async def console_http(request: Request, path: str = "") -> Response: _register("hermes-terminal", HERMES_TERMINAL_UPSTREAM)
"""Statische ttyd-Assets (index.html, token) durchreichen. /console/ws läuft über die WS-Route."""
upstream = f"{BOX_CONSOLE_UPSTREAM}/console/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, upstream, content=await request.body())
# Content-Type erhalten; Hop-by-Hop-Header weglassen.
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("console_http: Upstream nicht erreichbar (%s)", exc)
return Response(content=b"Box-Konsole (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
+6 -5
View File
@@ -11,7 +11,8 @@ import re
import httpx import httpx
import psutil import psutil
from config import HERMES_TERMINAL_URL, BOX_CONSOLE_UPSTREAM, BOX_CONSOLE_PATH, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -139,13 +140,13 @@ def agent_status() -> dict:
return { return {
"gateway_url": HERMES_API_URL, "gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2. # Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt.
"terminal_url": HERMES_TERMINAL_URL, "terminal_url": HERMES_TERMINAL_PATH,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/). # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH, "box_console_url": BOX_CONSOLE_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"), "gateway_reachable": _reach(HERMES_API_URL, "/health"),
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"), # Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path).
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console). "terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"),
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"), "box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
"home_exists": home.exists(), "home_exists": home.exists(),
"brain_model": brain_model, "brain_model": brain_model,
+10 -7
View File
@@ -625,7 +625,10 @@ def os_update_job(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password(sudo_password):
return err return err
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
cmd = ("sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y " # DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
# sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab.
cmd = ("sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}") f"&& bash {STACK_POSTCHECK}")
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
group="maintenance", sudo_password=sudo_password) group="maintenance", sudo_password=sudo_password)
@@ -637,8 +640,9 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
return None return None
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
if err := check_sudo_needs_password(sudo_password): # KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
return err # (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
def on_done(): def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich _engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
@@ -648,7 +652,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. # neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
"Engine-Update (llama.cpp Vulkan)", "Engine-Update (llama.cpp Vulkan)",
group="maintenance", on_done=on_done, sudo_password=sudo_password) group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
@@ -657,8 +661,7 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
return None return None
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
if err := check_sudo_needs_password(sudo_password): # KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
return err
def on_done(): def on_done():
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich _swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
@@ -668,7 +671,7 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. # Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD], job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
"Router-Update (llama-swap)", "Router-Update (llama-swap)",
group="maintenance", on_done=on_done, sudo_password=sudo_password) group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
+5 -4
View File
@@ -10,12 +10,13 @@ After=network.target
# #
# SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy # SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy
# (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von # (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von
# außen dicht → keine eigene Firewall-Regel nötig. Kein Passwort — bewusst, gleiches LAN-Trust- # außen dicht → keine eigene Firewall-Regel nötig. Login-Schutz: ttyd startet die Shell über
# Modell wie das Dashboard/Hermes-Terminal (kein Internet-Exposure). # `su - hitonabi` → fragt beim Öffnen das Box-Passwort ab (kein gespeichertes Passwort, PAM
# prüft gegen das echte Konto). Trusted-Home-LAN, kein Internet-Exposure.
Type=simple Type=simple
# --interface lo = nur Loopback. --base-path /console = ttyd bedient /console/* (Reverse-Proxy). # --interface lo = nur Loopback. --base-path /console = ttyd bedient /console/* (Reverse-Proxy).
# -t = Terminal-Optionen (dunkles Theme passend zum UI). # `su - hitonabi` = Passwort-Login (Box-Passwort) vor der Shell. -t = dunkles Theme.
ExecStart=/usr/bin/ttyd --writable --interface lo --port 7682 --base-path /console --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/bash -l ExecStart=/usr/bin/ttyd --writable --interface lo --port 7682 --base-path /console --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/su - hitonabi
Restart=always Restart=always
RestartSec=3 RestartSec=3
+8 -6
View File
@@ -7,13 +7,15 @@ After=network.target
# Exponiert die interaktive Hermes-Agent-CLI (`hermes chat`, voller Agent mit Tools/PC) als # Exponiert die interaktive Hermes-Agent-CLI (`hermes chat`, voller Agent mit Tools/PC) als
# Web-Terminal. Wird in MC2 per iframe eingebettet (Terminal-Seite) — Ersatz fuer AnythingLLM. # Web-Terminal. Wird in MC2 per iframe eingebettet (Terminal-Seite) — Ersatz fuer AnythingLLM.
# #
# SICHERHEIT: --writable + LAN-Bind ohne Auth = dasselbe Trust-Modell wie das MC2-Dashboard # SICHERHEIT: Bindet NUR an Loopback (lo) und laeuft hinter dem MC2-Reverse-Proxy
# (vertrautes Heim-LAN, kein Internet-Exposure). Fuer Basic-Auth: am ExecStart # (routers/console.py → same-origin /hermes-terminal/ auf dem offenen Port 9001) — keine eigene
# --credential <user>:<pass> # Firewall-Freigabe noetig. Login-Schutz: ttyd startet die Agent-CLI ueber `su - hitonabi -c …`
# ergaenzen (Browser fragt dann einmalig im iframe nach). # → fragt beim Oeffnen das Box-Passwort ab (PAM gegen das echte Konto, nichts gespeichert).
# Trusted-Home-LAN, kein Internet-Exposure.
Type=simple Type=simple
# --interface eno1 = LAN-Bind (box-spezifisch; eno1 traegt 192.168.178.151). # --interface lo = nur Loopback. --base-path /hermes-terminal = ttyd bedient /hermes-terminal/*.
ExecStart=/usr/bin/ttyd --writable --interface eno1 --port 7681 --max-clients 2 --cwd %h/.hermes %h/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main chat # `su - hitonabi -c` = Passwort-Login (Box-Passwort), danach `exec` in die Hermes-Agent-CLI.
ExecStart=/usr/bin/ttyd --writable --interface lo --port 7681 --base-path /hermes-terminal --max-clients 2 --cwd %h/.hermes /bin/su - hitonabi -c "cd %h/.hermes && exec %h/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main chat"
Restart=always Restart=always
RestartSec=3 RestartSec=3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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-DQC1USWi.js"></script> <script type="module" crossorigin src="/assets/index-DZk0tw7D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DfNbRJ2k.css"> <link rel="stylesheet" crossorigin href="/assets/index-DfNbRJ2k.css">
</head> </head>
<body> <body>
+22 -3
View File
@@ -158,10 +158,29 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
return false return false
} }
// Passwort-Fehler NICHT mehr verschlucken: OS-Update braucht das Box-Passwort. Früher
// lief die Aktion bei fehlendem/falschem Passwort ins Leere („passiert nichts").
function handledSudo(res: any): boolean {
if (res?.status === "password_required" || res?.status === "incorrect_password") {
showAlert(
"Box-Passwort nötig",
res.status === "incorrect_password"
? "Das gespeicherte Box-Passwort stimmt nicht. Bitte in der Konsole unter „Box-Zugang“ korrigieren (oder hier in den Einstellungen)."
: "Für dieses Update braucht die Box dein Passwort. Bitte in der Konsole unter „Box-Zugang“ setzen (oder hier in den Einstellungen).",
)
return true
}
if (res && res.ok === false) {
showAlert("Update fehlgeschlagen", res.err || "Unbekannter Fehler.")
return true
}
return false
}
async function triggerOsUpdate() { async function triggerOsUpdate() {
try { try {
const res = await api<any>("/api/maintenance/os-update", { method: "POST" }) const res = await api<any>("/api/maintenance/os-update", { method: "POST" })
if (handledBusy(res)) return if (handledBusy(res) || handledSudo(res)) return
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
@@ -172,7 +191,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
async function triggerEngineUpdate() { async function triggerEngineUpdate() {
try { try {
const res = await api<any>("/api/maintenance/engine-update", { method: "POST" }) const res = await api<any>("/api/maintenance/engine-update", { method: "POST" })
if (handledBusy(res)) return if (handledBusy(res) || handledSudo(res)) return
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
@@ -183,7 +202,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
async function triggerSwapUpdate() { async function triggerSwapUpdate() {
try { try {
const res = await api<any>("/api/maintenance/swap-update", { method: "POST" }) const res = await api<any>("/api/maintenance/swap-update", { method: "POST" })
if (handledBusy(res)) return if (handledBusy(res) || handledSudo(res)) return
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
+2 -1
View File
@@ -12,8 +12,9 @@ export default defineConfig({
proxy: { proxy: {
// Dev: /api → FastAPI-Backend (Override via MC_API_TARGET, z.B. die Box) // Dev: /api → FastAPI-Backend (Override via MC_API_TARGET, z.B. die Box)
"/api": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true }, "/api": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true },
// Box-Konsole (ttyd) läuft same-origin über den MC2-Reverse-Proxy (/console/ + /console/ws). // Web-Terminals (ttyd) laufen same-origin über den MC2-Reverse-Proxy (/<name>/ + /<name>/ws).
"/console": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true, ws: true }, "/console": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true, ws: true },
"/hermes-terminal": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true, ws: true },
}, },
}, },
build: { outDir: "dist" }, build: { outDir: "dist" },