fix(security): v3-Umbau P1 — Geheimnisse raus aus dem Browser, Frontend endlich getestet
Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.
## B-01 — Box-Sudo-Passwort: ersatzlos entfernt
Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.
AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:
frontend/src/lib/api.ts kein localStorage-Zugriff mehr
frontend/.../SettingsTab.tsx Eingabefeld weg, dafuer die Erklaerung warum
backend/routers/maintenance.py SudoReq entfaellt, 8 Endpunkte entschlackt
backend/services/maintenance.py _run() nutzt immer `sudo -n`
backend/services/jobengine.py keine stdin-Pipe mehr (DEVNULL)
`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.
## HuggingFace-Token: liegt jetzt auf der Box
Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.
## B-14 — Fehlermeldungen sagen jetzt, was los ist
api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).
## B-07 — Tests und Linter, ehrlich eingeordnet
Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.
Vitest + Testing Library, 17 Tests in 3 Dateien
ESLint (flat config) + Prettier
Beides jetzt Teil der Ampel
Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).
ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.
## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy
Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).
## Nachgemessen
Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler
Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8aa22e6591
commit
ae69c5ce31
@@ -9,7 +9,8 @@
|
|||||||
#
|
#
|
||||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
# Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
|
||||||
|
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
|
||||||
name: Ampel
|
name: Ampel
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
@@ -43,6 +44,20 @@ jobs:
|
|||||||
else
|
else
|
||||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||||
|
|
||||||
|
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
|
||||||
|
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
|
||||||
|
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
|
||||||
|
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
|
||||||
|
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
|
||||||
|
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
|
||||||
|
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
|
||||||
|
if [ $rot -eq 0 ]; then
|
||||||
|
echo "-- Vitest"
|
||||||
|
( cd frontend && npm test --silent ) || rot=1
|
||||||
|
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
|
||||||
|
( cd frontend && npm run lint --silent ) || rot=1
|
||||||
|
fi
|
||||||
|
|
||||||
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
|
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
|
||||||
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
|
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
|
||||||
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
|
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
||||||
|
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
|
||||||
|
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
|
||||||
|
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
|
||||||
|
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
|
||||||
|
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
|
||||||
|
mit einem im Browser geparkten Geheimnis übertüncht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import maintenance
|
from services import geheimnisse, maintenance
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
class SudoReq(BaseModel):
|
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RestartReq(BaseModel):
|
class RestartReq(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
class GeheimnisReq(BaseModel):
|
||||||
|
schluessel: str
|
||||||
|
wert: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/updates")
|
@router.get("/maintenance/updates")
|
||||||
@@ -28,32 +37,32 @@ def update_details(kind: str) -> dict:
|
|||||||
return maintenance.update_details(kind)
|
return maintenance.update_details(kind)
|
||||||
|
|
||||||
@router.post("/maintenance/check-updates")
|
@router.post("/maintenance/check-updates")
|
||||||
def check_updates(body: SudoReq) -> dict:
|
def check_updates() -> dict:
|
||||||
res = maintenance.check_updates_job(body.sudo_password)
|
res = maintenance.check_updates_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/os-update")
|
@router.post("/maintenance/os-update")
|
||||||
def os_update(body: SudoReq) -> dict:
|
def os_update() -> dict:
|
||||||
res = maintenance.os_update_job(body.sudo_password)
|
res = maintenance.os_update_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/engine-update")
|
@router.post("/maintenance/engine-update")
|
||||||
def engine_update(body: SudoReq) -> dict:
|
def engine_update() -> dict:
|
||||||
res = maintenance.engine_update_job(body.sudo_password)
|
res = maintenance.engine_update_job()
|
||||||
if not res:
|
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 res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/swap-update")
|
@router.post("/maintenance/swap-update")
|
||||||
def swap_update(body: SudoReq) -> dict:
|
def swap_update() -> dict:
|
||||||
res = maintenance.swap_update_job(body.sudo_password)
|
res = maintenance.swap_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
@@ -65,20 +74,34 @@ def hermes_update() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/update-all")
|
@router.post("/maintenance/update-all")
|
||||||
def update_all(body: SudoReq) -> dict:
|
def update_all() -> dict:
|
||||||
return maintenance.update_all_job(body.sudo_password)
|
return maintenance.update_all_job()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/reboot")
|
@router.post("/maintenance/reboot")
|
||||||
def reboot(body: SudoReq) -> dict:
|
def reboot() -> dict:
|
||||||
return maintenance.reboot(body.sudo_password)
|
return maintenance.reboot()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/restart")
|
@router.post("/maintenance/restart")
|
||||||
def restart(body: RestartReq) -> dict:
|
def restart(body: RestartReq) -> dict:
|
||||||
return maintenance.restart_service(body.service, body.sudo_password)
|
return maintenance.restart_service(body.service)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/logs")
|
@router.get("/maintenance/logs")
|
||||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
return maintenance.logs(service, lines, x_sudo_password)
|
return maintenance.logs(service, lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maintenance/geheimnisse")
|
||||||
|
def geheimnisse_status() -> dict:
|
||||||
|
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
||||||
|
return geheimnisse.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/geheimnisse")
|
||||||
|
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
||||||
|
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
||||||
|
if not geheimnisse.setzen(body.schluessel, body.wert):
|
||||||
|
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
||||||
|
return {"ok": True, **geheimnisse.status()}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import psutil
|
|||||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import budget, discover, hf, jobengine, llamaswap
|
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||||
from services.fit import evaluate_fit, max_ctx_for
|
from services.fit import evaluate_fit, max_ctx_for
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -77,7 +77,9 @@ class InstallReq(BaseModel):
|
|||||||
quant: str = "Q4_K_M"
|
quant: str = "Q4_K_M"
|
||||||
ctx: int | None = None
|
ctx: int | None = None
|
||||||
jinja: bool = False
|
jinja: bool = False
|
||||||
hf_token: str | None = None
|
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||||
|
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
|
||||||
|
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hf/search")
|
@router.get("/hf/search")
|
||||||
@@ -144,8 +146,8 @@ def install(req: InstallReq) -> dict:
|
|||||||
args.append(info["mmproj"])
|
args.append(info["mmproj"])
|
||||||
args += ["--local-dir", str(target)]
|
args += ["--local-dir", str(target)]
|
||||||
env = dict(HF_DOWNLOAD_ENV)
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if token := geheimnisse.hf_token():
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = token
|
||||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||||
on_done=_apply_role if role else None)
|
on_done=_apply_role if role else None)
|
||||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
|
||||||
|
|
||||||
|
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
|
||||||
|
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
|
||||||
|
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
|
||||||
|
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
|
||||||
|
gleichbedeutend mit Root auf der Box gewesen.
|
||||||
|
|
||||||
|
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
|
||||||
|
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
|
||||||
|
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
|
||||||
|
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
|
||||||
|
|
||||||
|
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
|
||||||
|
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
|
||||||
|
erreicht — nicht, dass die Datei unlesbar wäre.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
|
||||||
|
|
||||||
|
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
|
||||||
|
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
|
||||||
|
ERLAUBT = frozenset({"hf_token"})
|
||||||
|
|
||||||
|
|
||||||
|
def _lesen() -> dict[str, str]:
|
||||||
|
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
|
||||||
|
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(PFAD.read_text(encoding="utf-8"))
|
||||||
|
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _schreiben(daten: dict[str, str]) -> bool:
|
||||||
|
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
|
||||||
|
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
|
||||||
|
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
|
||||||
|
try:
|
||||||
|
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = PFAD.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
|
||||||
|
tmp.replace(PFAD)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def hf_token() -> str | None:
|
||||||
|
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
|
||||||
|
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
|
||||||
|
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
|
||||||
|
|
||||||
|
|
||||||
|
def setzen(schluessel: str, wert: str | None) -> bool:
|
||||||
|
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
|
||||||
|
if schluessel not in ERLAUBT:
|
||||||
|
return False
|
||||||
|
daten = _lesen()
|
||||||
|
if wert:
|
||||||
|
daten[schluessel] = wert
|
||||||
|
else:
|
||||||
|
daten.pop(schluessel, None)
|
||||||
|
return _schreiben(daten)
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> dict[str, bool]:
|
||||||
|
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
|
||||||
|
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
|
||||||
|
daten = _lesen()
|
||||||
|
return {
|
||||||
|
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
|
||||||
|
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
|
||||||
|
"schreibbar": _schreibbar(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _schreibbar() -> bool:
|
||||||
|
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
|
||||||
|
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
|
||||||
|
try:
|
||||||
|
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return os.access(PFAD.parent, os.W_OK)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
@@ -57,39 +57,29 @@ def _pump_output(job: dict, stream) -> None:
|
|||||||
commit()
|
commit()
|
||||||
|
|
||||||
|
|
||||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
def _run_job(job_id: str, args: list[str], env: dict | None = None):
|
||||||
|
"""Job-Prozess starten und mitschreiben.
|
||||||
|
|
||||||
|
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
|
||||||
|
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
|
||||||
|
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
|
||||||
|
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
|
||||||
|
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
|
||||||
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(
|
||||||
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
stdin=subprocess.PIPE if sudo_password is not None else None,
|
stdin=subprocess.DEVNULL,
|
||||||
bufsize=0,
|
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:
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Fehler: {exc}")
|
_append_log(job, f"[mc] Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
job["state"] = "failed"
|
||||||
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
|||||||
|
|
||||||
|
|
||||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
group: 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)
|
log_args = list(args)
|
||||||
JOBS[job_id] = {
|
JOBS[job_id] = {
|
||||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||||
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=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, sudo_password), daemon=True).start()
|
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+801
-802
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 110,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as A,I as P,f as M,k as B,u as D,r as z,j as e,J as T,b as a,K as g,M as b,N as W,t as f,W as j,O as G,X as I,C as N,n as y,q as i}from"./index-B57enqf6.js";/**
|
import{c as A,I as P,f as M,k as B,u as D,r as z,j as e,J as T,b as a,K as g,M as b,N as W,t as f,W as j,O as G,X as I,C as N,n as y,q as i}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as W,a1 as H,a2 as F,u as G,k as T,r as b,j as e,R as _,b as y,L as m,C as E,_ as O,D as P,a0 as z,a3 as R,a4 as V,X as D,T as C,F as K,n as $,a5 as q,q as U}from"./index-B57enqf6.js";import{S,H as Q}from"./SectionLabel-C_SwIL8k.js";import{C as M}from"./chevron-down-C-RXUrpR.js";/**
|
import{c as W,a1 as H,a2 as F,u as G,k as T,r as b,j as e,R as _,b as y,L as m,C as E,_ as O,D as P,a0 as z,a3 as R,a4 as V,X as D,T as C,F as K,n as $,a5 as q,q as U}from"./index-BYlMs6Dj.js";import{S,H as Q}from"./SectionLabel-R1yae0WQ.js";import{C as M}from"./chevron-down-D5zhK_UQ.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as d,aj as S,r as h,j as e,ak as M,L as p,a0 as z,a5 as C,al as D,am as A,b as B,an as Z,u as E,k as L,n as k,q}from"./index-B57enqf6.js";import{R as j}from"./rotate-ccw-Bz6oQ3_7.js";/**
|
import{c as d,aj as S,r as h,j as e,ak as M,L as p,a0 as z,a5 as C,al as D,am as A,b as B,an as Z,u as E,k as L,n as k,q}from"./index-BYlMs6Dj.js";import{R as j}from"./rotate-ccw-Dx0bZ_kA.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as i,r as p,z as G,A as H,j as e,D as u,t as z,B as I,F as M,b as S,L as B,G as _,C as E}from"./index-B57enqf6.js";import{C as P}from"./chevron-down-C-RXUrpR.js";/**
|
import{c as i,r as p,z as G,A as H,j as e,D as u,t as z,B as I,F as M,b as S,L as B,G as _,C as E}from"./index-BYlMs6Dj.js";import{C as P}from"./chevron-down-D5zhK_UQ.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as u,j as e,r as M,t as C,V as z,Y as k,Z as y,M as v,_ as L,O as G,$ as D,W as T,b as f,a0 as B}from"./index-B57enqf6.js";import{a as w,L as P,C as E,Z as R}from"./zap-0IkDqAhv.js";import{L as I}from"./layers-BVbGNbDq.js";/**
|
import{c as u,j as e,r as M,t as C,V as z,Y as k,Z as y,M as v,_ as L,O as G,$ as D,W as T,b as f,a0 as B}from"./index-BYlMs6Dj.js";import{a as w,L as P,C as E,Z as R}from"./zap-B_VSdjfC.js";import{L as I}from"./layers-DCwnbkXd.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as $,j as e,b as S,a2 as ve,u as Se,k as De,r as d,n as z,a6 as Ke,_ as O,L as D,X as Q,a7 as xe,F as T,a3 as he,C as Ce,T as ze,a4 as Ee,a8 as pe,q as Ae,R as fe}from"./index-B57enqf6.js";import{S as Le,H as X}from"./SectionLabel-C_SwIL8k.js";import{C as ee}from"./code-xml-ID_kOxxM.js";import{L as Pe}from"./layers-BVbGNbDq.js";import{C as U}from"./chevron-down-C-RXUrpR.js";import{R as We}from"./rotate-ccw-Bz6oQ3_7.js";/**
|
import{c as $,j as e,b as S,a2 as ve,u as Se,k as De,r as d,n as z,a6 as Ke,_ as O,L as D,X as Q,a7 as xe,F as T,a3 as he,C as Ce,T as ze,a4 as Ee,a8 as pe,q as Ae,R as fe}from"./index-BYlMs6Dj.js";import{S as Le,H as X}from"./SectionLabel-R1yae0WQ.js";import{C as ee}from"./code-xml-DZ6lLr-w.js";import{L as Pe}from"./layers-DCwnbkXd.js";import{C as U}from"./chevron-down-D5zhK_UQ.js";import{R as We}from"./rotate-ccw-Dx0bZ_kA.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-B57enqf6.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-BYlMs6Dj.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-B57enqf6.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
|
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-BYlMs6Dj.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as s,j as e}from"./index-B57enqf6.js";/**
|
import{c as s,j as e}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
var M=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||M("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?M("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a9 as B,aa as F,ab as E,ac as I,ad as P,u as A,r as f,ae as J,af as Q,c as T,ag as U,j as s,G as D,T as H,b as K,L,M as G,q as V,n as R}from"./index-B57enqf6.js";import{J as _}from"./JobsBar-D8URwRCr.js";var c,g,o,h,m,w,C,q,z=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&E(t.mutationKey)!==E(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new z(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
var M=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||M("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?M("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a9 as B,aa as F,ab as E,ac as I,ad as P,u as A,r as f,ae as J,af as Q,c as T,ag as U,j as s,G as D,T as H,b as K,L,M as G,q as V,n as R}from"./index-BYlMs6Dj.js";import{J as _}from"./JobsBar-B1nmMx5i.js";var c,g,o,h,m,w,C,q,z=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&E(t.mutationKey)!==E(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new z(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as o}from"./index-B57enqf6.js";/**
|
import{c as o}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as e}from"./index-B57enqf6.js";/**
|
import{c as e}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
File diff suppressed because one or more lines are too long
-359
File diff suppressed because one or more lines are too long
+360
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as a}from"./index-B57enqf6.js";/**
|
import{c as a}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as t}from"./index-B57enqf6.js";/**
|
import{c as t}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as a}from"./index-B57enqf6.js";/**
|
import{c as a}from"./index-BYlMs6Dj.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<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-B57enqf6.js"></script>
|
<script type="module" crossorigin src="/assets/index-BYlMs6Dj.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-qIFdAPfr.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-8N0vA58P.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>␍
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// ESLint (flat config) — v3-Umbau P1.
|
||||||
|
//
|
||||||
|
// Warum ueberhaupt: In dieses Repo schreiben nachts auch Agenten. Ohne Waechter laeuft
|
||||||
|
// der Stil auseinander, und typische React-Fehler (fehlende Hook-Abhaengigkeiten,
|
||||||
|
// bedingt aufgerufene Hooks) fallen erst im Betrieb auf.
|
||||||
|
//
|
||||||
|
// Bewusst streng bei Korrektheit, bewusst still bei Geschmack: Formatierung macht
|
||||||
|
// Prettier, nicht der Linter. Regeln, die nur Meinung sind, stehen hier nicht.
|
||||||
|
|
||||||
|
import js from "@eslint/js"
|
||||||
|
import globals from "globals"
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks"
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh"
|
||||||
|
import tseslint from "typescript-eslint"
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", "node_modules", "*.config.js"] },
|
||||||
|
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
|
||||||
|
// Die Regeln aus der React-Compiler-Generation (Plugin v7) finden im Bestand
|
||||||
|
// 18 echte Stellen: setState direkt im Effekt, Ref-Zugriff waehrend des Renderns,
|
||||||
|
// Komponenten im Render erzeugt. Das sind keine Fehlalarme — aber sie quer durch
|
||||||
|
// 10 000 Zeilen zu beheben ist die Arbeit von Etappe P5 (Feature-Slices), nicht
|
||||||
|
// von P1. Als Fehler waere das Gate ab Tag eins rot, und ein dauerhaft rotes Gate
|
||||||
|
// ist kein Gate mehr ("Rot ist ein Ergebnis, kein Aergernis").
|
||||||
|
// Darum: sichtbare Warn-Liste statt stumm abgeschaltet. Beim Zerlegen eines
|
||||||
|
// Bereichs wird sein Anteil mit erledigt; ist der Zaehler bei 0, kommen sie
|
||||||
|
// hier auf "error" und der Rueckfall ist verbaut.
|
||||||
|
"react-hooks/set-state-in-effect": "warn",
|
||||||
|
"react-hooks/refs": "warn",
|
||||||
|
"react-hooks/immutability": "warn",
|
||||||
|
"react-hooks/static-components": "warn",
|
||||||
|
|
||||||
|
// `any` ist in diesem Projekt an einigen Stellen bewusst gesetzt (Recharts-Rueckrufe,
|
||||||
|
// Fehlerobjekte in catch). Warnen statt blockieren — sonst wird die Regel abgeschaltet.
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
|
||||||
|
// Ungenutzte Variablen sind fast immer ein Rest von etwas Halbfertigem.
|
||||||
|
// Fuehrendes _ heisst "absichtlich ungenutzt" (Rueckrufe mit fester Signatur).
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Der Zaun um B-01: Geheimnisse gehoeren auf die Box, nicht in den Browser-Speicher.
|
||||||
|
// Wer localStorage fuer Bedienvorlieben nutzt, ist willkommen — wer ein Passwort oder
|
||||||
|
// einen Token hineinschreibt, soll hier haengen bleiben.
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"CallExpression[callee.object.name='localStorage'][arguments.0.value=/(?:password|passwort|token|secret|geheim|api_?key)/i]",
|
||||||
|
message:
|
||||||
|
"Geheimnisse gehoeren nicht in den localStorage (v3-Umbau P1, Befund B-01). " +
|
||||||
|
"Auf der Box ablegen: backend/services/geheimnisse.py.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tests duerfen mehr: Attrappen brauchen `any`, und sie schreiben absichtlich
|
||||||
|
// Geheimnisse in den localStorage, um zu beweisen, dass api() sie NICHT mitschickt.
|
||||||
|
{
|
||||||
|
files: ["src/**/*.test.{ts,tsx}", "src/test/**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"no-restricted-syntax": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
Generated
+6340
-3850
File diff suppressed because it is too large
Load Diff
+53
-37
@@ -1,37 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "mission-control-2-frontend",
|
"name": "mission-control-2-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
},
|
"test": "vitest run",
|
||||||
"dependencies": {
|
"test:watch": "vitest",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"lint": "eslint src",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
||||||
"@fontsource/space-grotesk": "^5.2.10",
|
},
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"cmdk": "^1.1.1",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"d3-force": "^3.0.0",
|
"@fontsource/space-grotesk": "^5.2.10",
|
||||||
"lucide-react": "^0.460.0",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"react": "^18.3.1",
|
"clsx": "^2.1.1",
|
||||||
"react-dom": "^18.3.1",
|
"cmdk": "^1.1.1",
|
||||||
"react-force-graph-2d": "^1.29.1",
|
"d3-force": "^3.0.0",
|
||||||
"recharts": "^3.9.0",
|
"lucide-react": "^0.460.0",
|
||||||
"tailwind-merge": "^2.5.5"
|
"react": "^18.3.1",
|
||||||
},
|
"react-dom": "^18.3.1",
|
||||||
"devDependencies": {
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"recharts": "^3.9.0",
|
||||||
"@types/d3-force": "^3.0.10",
|
"tailwind-merge": "^2.5.5"
|
||||||
"@types/node": "^22.10.1",
|
},
|
||||||
"@types/react": "^18.3.12",
|
"devDependencies": {
|
||||||
"@types/react-dom": "^18.3.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"typescript": "^5.6.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"vite": "^6.0.3"
|
"@testing-library/user-event": "^14.6.6",
|
||||||
}
|
"@types/d3-force": "^3.0.10",
|
||||||
}
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^10.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.5",
|
||||||
|
"globals": "^17.11.0",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"typescript-eslint": "^8.68.0",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { LiveAreaChart } from "./LiveAreaChart"
|
||||||
|
import type { ChartSeries } from "./chartTypes"
|
||||||
|
|
||||||
|
const SERIEN: ChartSeries[] = [{ key: "cpu", label: "CPU", color: "#2dd4bf" }]
|
||||||
|
const DATEN = [
|
||||||
|
{ t: 1_700_000_000_000, cpu: 12 },
|
||||||
|
{ t: 1_700_000_003_000, cpu: 34 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Die Lazy-Huelle ist der Grund, warum Recharts nicht mehr im Startbuendel liegt (P0).
|
||||||
|
// Wichtig ist dabei nur eines: Der Platzhalter muss GENAU so hoch sein wie das spaetere
|
||||||
|
// Diagramm — sonst springt die Karte beim Nachladen.
|
||||||
|
describe("LiveAreaChart (Lazy-Huelle)", () => {
|
||||||
|
it("haelt vor dem Nachladen die volle Diagrammhoehe frei", () => {
|
||||||
|
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||||
|
const platzhalter = container.querySelector<HTMLElement>('[aria-hidden="true"]')
|
||||||
|
expect(platzhalter).not.toBeNull()
|
||||||
|
expect(platzhalter!.style.height).toBe("176px")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uebernimmt eine abweichende Hoehe", () => {
|
||||||
|
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={96} />)
|
||||||
|
expect(container.querySelector<HTMLElement>('[aria-hidden="true"]')!.style.height).toBe("96px")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("laedt die Recharts-Umsetzung nach und zeigt sie an", async () => {
|
||||||
|
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector(".recharts-responsive-container")).not.toBeNull()
|
||||||
|
})
|
||||||
|
// Platzhalter ist verschwunden, sobald das Diagramm da ist.
|
||||||
|
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ist fuer Screenreader stumm — der Platzhalter ist reine Optik", () => {
|
||||||
|
render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||||
|
expect(screen.queryByText(/l(ä|ae)dt/i)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,123 +1,182 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Eye, EyeOff, Key, Shield } from "lucide-react"
|
import { Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"
|
||||||
|
import { api } from "@/lib/api"
|
||||||
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
|
|
||||||
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
|
// Einstellungen-Tab des SystemDrawers.
|
||||||
// `showAlert` (gemeinsamer Dialog).
|
//
|
||||||
export function SettingsTab({ open, showAlert }: {
|
// v3-Umbau P1 (28.08.2026): Hier standen zwei Geheimnisse, die im localStorage des
|
||||||
open: boolean
|
// Browsers lagen und bei jedem mutierenden Request mitreisten.
|
||||||
showAlert: (title: string, message: string) => void
|
//
|
||||||
}) {
|
// · Das Box-Sudo-Passwort ist ERSATZLOS weg. Auf der Box gemessen: `sudo -n true`
|
||||||
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
|
// laeuft durch, weil /etc/sudoers den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt.
|
||||||
const [hfTokenInput, setHfTokenInput] = useState("")
|
// Das Feld hat also nie etwas bewirkt — es war reines Risiko (ein XSS in dieser
|
||||||
const [showSudo, setShowSudo] = useState(false)
|
// SPA haette Root bedeutet).
|
||||||
const [showHf, setShowHf] = useState(false)
|
// · Der HuggingFace-Token liegt jetzt auf der Box (services/geheimnisse.py, 0600).
|
||||||
|
// Diese Ansicht erfaehrt nur noch, OB einer gesetzt ist — nie seinen Wert. Deshalb
|
||||||
useEffect(() => {
|
// gibt es kein "Anzeigen" fuer einen gespeicherten Token, sondern nur Ersetzen
|
||||||
if (open) {
|
// oder Loeschen.
|
||||||
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
|
|
||||||
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
|
interface GeheimnisStatus {
|
||||||
}
|
hf_token_gesetzt: boolean
|
||||||
}, [open])
|
hf_token_aus_env: boolean
|
||||||
|
schreibbar: boolean
|
||||||
return (
|
}
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
export function SettingsTab({ open, showAlert }: {
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
open: boolean
|
||||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
showAlert: (title: string, message: string) => void
|
||||||
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
|
}) {
|
||||||
</p>
|
const [status, setStatus] = useState<GeheimnisStatus | null>(null)
|
||||||
</div>
|
const [eingabe, setEingabe] = useState("")
|
||||||
|
const [sichtbar, setSichtbar] = useState(false)
|
||||||
{/* Sudo Passwort */}
|
const [laedt, setLaedt] = useState(false)
|
||||||
<div className="space-y-2">
|
const [arbeitet, setArbeitet] = useState(false)
|
||||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
|
||||||
<Shield className="h-4 w-4 text-amber-400" />
|
async function statusLaden() {
|
||||||
Host Sudo-Passwort
|
setLaedt(true)
|
||||||
</label>
|
try {
|
||||||
<div className="relative">
|
setStatus(await api<GeheimnisStatus>("/api/maintenance/geheimnisse"))
|
||||||
<input
|
} catch (e: any) {
|
||||||
type={showSudo ? "text" : "password"}
|
showAlert("Nicht erreichbar", `Der Geheimnis-Zustand konnte nicht geladen werden: ${e?.message || e}`)
|
||||||
value={sudoPasswordInput}
|
} finally {
|
||||||
onChange={(e) => setSudoPasswordInput(e.target.value)}
|
setLaedt(false)
|
||||||
aria-label="Host Sudo-Passwort"
|
}
|
||||||
name="sudo-password"
|
}
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
useEffect(() => {
|
||||||
placeholder="Sudo-Passwort für System-Operationen"
|
if (open) {
|
||||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
setEingabe("")
|
||||||
/>
|
setSichtbar(false)
|
||||||
<button
|
void statusLaden()
|
||||||
type="button"
|
}
|
||||||
onClick={() => setShowSudo(!showSudo)}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
|
}, [open])
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
async function setzen(wert: string | null) {
|
||||||
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
setArbeitet(true)
|
||||||
</button>
|
try {
|
||||||
</div>
|
const r = await api<GeheimnisStatus>("/api/maintenance/geheimnisse", {
|
||||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
method: "POST",
|
||||||
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
|
body: JSON.stringify({ schluessel: "hf_token", wert }),
|
||||||
</p>
|
})
|
||||||
</div>
|
setStatus(r)
|
||||||
|
setEingabe("")
|
||||||
{/* HuggingFace Token */}
|
showAlert(wert ? "Gespeichert" : "Gelöscht",
|
||||||
<div className="space-y-2">
|
wert ? "Der Token liegt jetzt auf der Box — der Browser behält ihn nicht."
|
||||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
: "Der Token wurde von der Box entfernt.")
|
||||||
<Key className="h-4 w-4 text-violet-400" />
|
} catch (e: any) {
|
||||||
HuggingFace API Token
|
showAlert("Fehlgeschlagen", e?.message || String(e))
|
||||||
</label>
|
} finally {
|
||||||
<div className="relative">
|
setArbeitet(false)
|
||||||
<input
|
}
|
||||||
type={showHf ? "text" : "password"}
|
}
|
||||||
value={hfTokenInput}
|
|
||||||
onChange={(e) => setHfTokenInput(e.target.value)}
|
return (
|
||||||
aria-label="HuggingFace API Token"
|
<div className="space-y-6">
|
||||||
name="hf-token"
|
<div className="space-y-2">
|
||||||
autoComplete="off"
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||||
spellCheck={false}
|
<p className="text-[10px] leading-normal text-muted-foreground">
|
||||||
placeholder="hf_…"
|
Geheimnisse liegen auf der Box (Datei mit Rechten 0600), nicht im Browser. Diese Seite
|
||||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
zeigt nur, ob etwas gesetzt ist — den Wert bekommt sie nie zurück.
|
||||||
/>
|
</p>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowHf(!showHf)}
|
{/* Sudo: bewusst nur noch eine Erklaerung, kein Eingabefeld. */}
|
||||||
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
|
<div className="mc-card-sm space-y-1.5 p-3">
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
<div className="flex items-center gap-1.5 text-xs font-semibold">
|
||||||
>
|
<ShieldCheck className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||||
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
Box-Sudo-Passwort — nicht mehr nötig
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<p className="text-[10px] leading-normal text-muted-foreground">
|
||||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
Systemdienste, OS-Update und Neustart laufen passwortlos (<code className="font-mono text-primary">NOPASSWD</code> in
|
||||||
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
|
<code className="font-mono"> /etc/sudoers</code>). Das frühere Feld hat nie etwas bewirkt und lag dabei
|
||||||
</p>
|
im Browser-Speicher — es ist am 28.08.2026 ersatzlos entfallen. Verlangt <code className="font-mono">sudo</code> auf
|
||||||
</div>
|
der Box je doch ein Passwort, sagt die Wartung das im Klartext; das ist dann dort zu lösen.
|
||||||
|
</p>
|
||||||
{/* Buttons */}
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
{/* HuggingFace-Token */}
|
||||||
onClick={() => {
|
<div className="space-y-2">
|
||||||
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
|
<label htmlFor="hf-token" className="flex items-center gap-1.5 text-xs font-semibold">
|
||||||
localStorage.setItem("mc_hf_token", hfTokenInput);
|
<Key className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
||||||
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
|
HuggingFace-Token
|
||||||
}}
|
</label>
|
||||||
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
|
|
||||||
>
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
Speichern
|
{laedt ? (
|
||||||
</button>
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
<button
|
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> Zustand wird geladen …
|
||||||
onClick={() => {
|
</span>
|
||||||
setSudoPasswordInput("");
|
) : status?.hf_token_gesetzt ? (
|
||||||
setHfTokenInput("");
|
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
|
||||||
localStorage.removeItem("mc_sudo_password");
|
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||||
localStorage.removeItem("mc_hf_token");
|
Gesetzt{status.hf_token_aus_env ? " (aus der Dienst-Umgebung)" : ""}
|
||||||
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
|
</span>
|
||||||
}}
|
) : (
|
||||||
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
>
|
<span className="h-2 w-2 rounded-full bg-muted-foreground/50" aria-hidden="true" />
|
||||||
Zurücksetzen
|
Nicht gesetzt
|
||||||
</button>
|
</span>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
}
|
{status?.hf_token_aus_env && (
|
||||||
|
<p className="text-[9px] leading-normal text-amber-400/90">
|
||||||
|
Der Token kommt aus der Umgebung des Dienstes und hat Vorrang. Hier gespeicherte Werte
|
||||||
|
bleiben wirkungslos, solange <code className="font-mono">HF_TOKEN</code> gesetzt ist.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && !status.schreibbar && (
|
||||||
|
<p className="text-[9px] leading-normal text-amber-400/90">
|
||||||
|
Die Ablage ist nicht beschreibbar — auf einem Entwicklungsrechner ohne den Modell-Ordner
|
||||||
|
der Box ist das normal. Speichern würde hier ins Leere laufen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="hf-token"
|
||||||
|
type={sichtbar ? "text" : "password"}
|
||||||
|
value={eingabe}
|
||||||
|
onChange={(e) => setEingabe(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={status?.hf_token_gesetzt ? "Neuen Token eingeben, um zu ersetzen" : "hf_…"}
|
||||||
|
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSichtbar(!sichtbar)}
|
||||||
|
aria-label={sichtbar ? "Token verbergen" : "Token anzeigen"}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{sichtbar ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] leading-normal text-muted-foreground">
|
||||||
|
Nötig für Modelle mit Zugangsschranke. Wird beim Download als
|
||||||
|
<code className="font-mono"> HF_TOKEN</code> an den Job gereicht und verlässt die Box nicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setzen(eingabe.trim())}
|
||||||
|
disabled={arbeitet || !eingabe.trim()}
|
||||||
|
className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{arbeitet && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||||
|
Auf der Box speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setzen(null)}
|
||||||
|
disabled={arbeitet || !status?.hf_token_gesetzt}
|
||||||
|
className="flex h-9 cursor-pointer items-center justify-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { ApiError, api } from "./api"
|
||||||
|
|
||||||
|
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
|
||||||
|
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
|
||||||
|
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
|
||||||
|
try {
|
||||||
|
await api(pfad, init)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return e
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
|
||||||
|
function antwort(status: number, rumpf: string, ok = status < 400): Response {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
|
||||||
|
text: async () => rumpf,
|
||||||
|
json: async () => JSON.parse(rumpf),
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAttrappe(res: Response) {
|
||||||
|
const spion = vi.fn(async () => res)
|
||||||
|
vi.stubGlobal("fetch", spion)
|
||||||
|
return spion
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe("api()", () => {
|
||||||
|
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
|
||||||
|
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
|
||||||
|
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
|
||||||
|
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
|
||||||
|
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
|
||||||
|
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
|
||||||
|
await expect(api("/api/maintenance/restart", { method: "POST" }))
|
||||||
|
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
|
||||||
|
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
|
||||||
|
await expect(api("/api/models/install", { method: "POST" }))
|
||||||
|
.rejects.toThrowError("repo fehlt; quant ungueltig")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
|
||||||
|
fetchAttrappe(antwort(500, ""))
|
||||||
|
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
|
||||||
|
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
|
||||||
|
const fehler = await fehlerVon("/api/test")
|
||||||
|
expect(fehler.status).toBe(502)
|
||||||
|
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
|
||||||
|
expect(typeof fehler.message).toBe("string")
|
||||||
|
expect(fehler.message.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("traegt status und detail am Fehlerobjekt", async () => {
|
||||||
|
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
|
||||||
|
const fehler = await fehlerVon("/api/test")
|
||||||
|
expect(fehler.status).toBe(400)
|
||||||
|
expect(fehler.detail).toBe("kaputt")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
|
||||||
|
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
|
||||||
|
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
|
||||||
|
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
|
||||||
|
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
|
||||||
|
// Bequemlichkeit je wieder einbaut, macht sie rot.
|
||||||
|
describe("api() traegt keine Geheimnisse", () => {
|
||||||
|
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
|
||||||
|
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||||
|
localStorage.setItem("mc_hf_token", "hf_geheim")
|
||||||
|
|
||||||
|
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||||
|
await api("/api/maintenance/restart", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ service: "llama-swap" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||||
|
const kopfzeilen = JSON.stringify(init.headers ?? {})
|
||||||
|
const rumpf = String(init.body ?? "")
|
||||||
|
|
||||||
|
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
|
||||||
|
expect(kopfzeilen).not.toContain("geheim123")
|
||||||
|
expect(rumpf).not.toContain("geheim123")
|
||||||
|
expect(rumpf).not.toContain("hf_geheim")
|
||||||
|
expect(rumpf).not.toContain("sudo_password")
|
||||||
|
expect(rumpf).not.toContain("hf_token")
|
||||||
|
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
|
||||||
|
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("erfindet auch ohne Rumpf keinen", async () => {
|
||||||
|
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||||
|
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||||
|
await api("/api/maintenance/reboot", { method: "POST" })
|
||||||
|
|
||||||
|
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||||
|
expect(init.body ?? null).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+647
-646
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { act, renderHook } from "@testing-library/react"
|
||||||
|
|
||||||
|
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel ueberleben). Fuer jeden Test
|
||||||
|
// eine frische Modul-Instanz, sonst tragen sich die Faelle gegenseitig Daten ein.
|
||||||
|
async function frischerStore() {
|
||||||
|
vi.resetModules()
|
||||||
|
return await import("./metricsStore")
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("metricsStore", () => {
|
||||||
|
let store: Awaited<ReturnType<typeof frischerStore>>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
store = await frischerStore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("beginnt leer", () => {
|
||||||
|
const { result } = renderHook(() => store.useSysHistory())
|
||||||
|
expect(result.current).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("meldet neue Messpunkte an die Abonnenten", () => {
|
||||||
|
const { result } = renderHook(() => store.useSysHistory())
|
||||||
|
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
|
||||||
|
expect(result.current).toHaveLength(1)
|
||||||
|
expect(result.current[0].cpu).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ohne Deckel waechst der Verlauf unbegrenzt — bei 3-s-Takt sind das ~28 800 Punkte
|
||||||
|
// pro Tag in einem Dauerlaeufer-Tab.
|
||||||
|
it("deckelt den Verlauf und behaelt die JUENGSTEN Punkte", () => {
|
||||||
|
const { result } = renderHook(() => store.useSysHistory())
|
||||||
|
act(() => {
|
||||||
|
for (let i = 0; i < 60; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
|
||||||
|
})
|
||||||
|
expect(result.current).toHaveLength(40)
|
||||||
|
expect(result.current[0].t).toBe(20)
|
||||||
|
expect(result.current[39].t).toBe(59)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("haelt System- und Token-Verlauf auseinander", () => {
|
||||||
|
const { result: sys } = renderHook(() => store.useSysHistory())
|
||||||
|
const { result: tok } = renderHook(() => store.useTokHistory())
|
||||||
|
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
|
||||||
|
expect(sys.current).toHaveLength(0)
|
||||||
|
expect(tok.current).toHaveLength(1)
|
||||||
|
expect(tok.current[0].completion).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
// useSyncExternalStore vergleicht per Referenz: wuerde pushSys das Array an Ort und
|
||||||
|
// Stelle veraendern, bliebe die Anzeige stehen, obwohl Daten da sind.
|
||||||
|
it("liefert bei jeder Aenderung ein NEUES Array (Referenzwechsel)", () => {
|
||||||
|
const { result } = renderHook(() => store.useSysHistory())
|
||||||
|
const vorher = result.current
|
||||||
|
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
|
||||||
|
expect(result.current).not.toBe(vorher)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Umgang mit einem veralteten Bündel (v3-Umbau P1).
|
||||||
|
//
|
||||||
|
// DAS PROBLEM, hier gemessen am 28.08.2026: Ein Deploy ersetzt `frontend/dist` und startet
|
||||||
|
// den Dienst neu. Offene Browser-Tabs behalten aber ihr altes, bereits geladenes
|
||||||
|
// Start-Bündel — und dessen Nachlade-Chunks (`IdeenView-J4hS7xB2.js` …) tragen den Hash
|
||||||
|
// des ALTEN Builds. Die Dateien sind weg. Beim nächsten Ansichtswechsel wirft der
|
||||||
|
// dynamische Import, die Fehlergrenze schlägt zu und der Nutzer sieht eine kaputte Seite,
|
||||||
|
// obwohl nur sein Tab alt ist.
|
||||||
|
//
|
||||||
|
// Vite meldet genau diesen Fall als `vite:preloadError`. Wir laden dann EINMAL neu — der
|
||||||
|
// Browser holt `index.html` (das nie gecacht wird, siehe backend/app.py) und damit das
|
||||||
|
// aktuelle Bündel. Die Sperre in sessionStorage verhindert eine Schleife, falls der Chunk
|
||||||
|
// aus einem anderen Grund fehlt (Netz weg, Datei wirklich kaputt): Dann bleibt es beim
|
||||||
|
// ehrlichen Fehler statt bei einer endlos neuladenden Seite.
|
||||||
|
|
||||||
|
const SPERRE = "mc_neuladen_wegen_version"
|
||||||
|
|
||||||
|
export function veralteteVersionAbfangen() {
|
||||||
|
window.addEventListener("vite:preloadError", (e) => {
|
||||||
|
if (sessionStorage.getItem(SPERRE)) return // schon versucht — Fehler durchlassen
|
||||||
|
e.preventDefault() // verhindert, dass der Import zusätzlich als Absturz hochblubbert
|
||||||
|
sessionStorage.setItem(SPERRE, "1")
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hat der Neustart geklappt, ist die Sperre nach dem nächsten erfolgreichen Start wieder
|
||||||
|
// frei — sonst wäre der Schutz nach dem ersten Deploy für diese Sitzung verbraucht.
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
window.setTimeout(() => sessionStorage.removeItem(SPERRE), 5_000)
|
||||||
|
})
|
||||||
|
}
|
||||||
+32
-27
@@ -1,27 +1,32 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||||
import "./index.css"
|
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
|
||||||
|
import "./index.css"
|
||||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
|
||||||
const queryClient = new QueryClient({
|
// Muss VOR dem ersten Rendern hängen: Nach einem Deploy sucht ein offener Tab
|
||||||
defaultOptions: {
|
// Nachlade-Chunks des alten Builds, die es nicht mehr gibt (siehe lib/veralteteVersion.ts).
|
||||||
queries: {
|
veralteteVersionAbfangen()
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||||
staleTime: 5_000,
|
const queryClient = new QueryClient({
|
||||||
},
|
defaultOptions: {
|
||||||
},
|
queries: {
|
||||||
})
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
staleTime: 5_000,
|
||||||
<React.StrictMode>
|
},
|
||||||
<AppErrorBoundary>
|
},
|
||||||
<QueryClientProvider client={queryClient}>
|
})
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
</AppErrorBoundary>
|
<React.StrictMode>
|
||||||
</React.StrictMode>,
|
<AppErrorBoundary>
|
||||||
)
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Test-Aufbau (v3-Umbau P1). Laeuft vor jeder Testdatei.
|
||||||
|
import "@testing-library/jest-dom/vitest"
|
||||||
|
import { cleanup } from "@testing-library/react"
|
||||||
|
import { afterEach } from "vitest"
|
||||||
|
|
||||||
|
// Nach jedem Test das DOM leeren — sonst finden Abfragen Reste des Vorgaengers.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// jsdom rechnet kein Layout: JEDES Element ist 0x0, und es gibt keinen ResizeObserver.
|
||||||
|
// Messende Komponenten (Recharts' ResponsiveContainer, der Kraftgraph) zeichnen daraufhin
|
||||||
|
// gar nichts und warnen "width(0) and height(0)".
|
||||||
|
//
|
||||||
|
// Diese Attrappe meldet den Beobachtern EINMAL eine plausible Groesse, damit solche
|
||||||
|
// Komponenten ueberhaupt rendern. Sie ist bewusst konstant: Tests hier pruefen Logik und
|
||||||
|
// Struktur, nie gemessene Pixel — auf echte Layout-Werte darf sich kein Test stuetzen.
|
||||||
|
const TEST_BREITE = 800
|
||||||
|
const TEST_HOEHE = 400
|
||||||
|
|
||||||
|
class TestResizeObserver implements ResizeObserver {
|
||||||
|
constructor(private rueckruf: ResizeObserverCallback) {}
|
||||||
|
|
||||||
|
observe(ziel: Element) {
|
||||||
|
const rect = {
|
||||||
|
width: TEST_BREITE, height: TEST_HOEHE, top: 0, left: 0,
|
||||||
|
right: TEST_BREITE, bottom: TEST_HOEHE, x: 0, y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRectReadOnly
|
||||||
|
const eintrag = {
|
||||||
|
target: ziel,
|
||||||
|
contentRect: rect,
|
||||||
|
borderBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||||
|
contentBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||||
|
devicePixelContentBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||||
|
} as unknown as ResizeObserverEntry
|
||||||
|
// Asynchron wie das Original — ein synchroner Rueckruf im Konstruktor-Pfad wuerde
|
||||||
|
// React mitten im Rendern aktualisieren.
|
||||||
|
queueMicrotask(() => this.rueckruf([eintrag], this))
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver
|
||||||
|
|
||||||
|
// Denselben Dienst fuer Komponenten, die direkt messen statt zu beobachten.
|
||||||
|
for (const [name, wert] of [["offsetWidth", TEST_BREITE], ["offsetHeight", TEST_HOEHE]] as const) {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, name, { configurable: true, value: wert })
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return
|
if (!containerRef.current) return
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
for (let entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||||
setDimensions({
|
setDimensions({
|
||||||
width: Math.floor(entry.contentRect.width),
|
width: Math.floor(entry.contentRect.width),
|
||||||
|
|||||||
@@ -1,314 +1,316 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp, Scale, Search } from "lucide-react"
|
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp, Scale, Search } from "lucide-react"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
|
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { fmtBytes } from "@/lib/format"
|
import { fmtBytes } from "@/lib/format"
|
||||||
import { FitBadge } from "@/components/models/ModelBadges"
|
import { FitBadge } from "@/components/models/ModelBadges"
|
||||||
import { ModelBrowse } from "./ModelBrowse"
|
import { ModelBrowse } from "./ModelBrowse"
|
||||||
|
|
||||||
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
|
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
|
||||||
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
|
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
|
||||||
fast: {
|
fast: {
|
||||||
title: "Schnelles Alltags-Hirn",
|
title: "Schnelles Alltags-Hirn",
|
||||||
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
|
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
|
||||||
icon: Zap
|
icon: Zap
|
||||||
},
|
},
|
||||||
heavy: {
|
heavy: {
|
||||||
title: "Schweres Reasoning",
|
title: "Schweres Reasoning",
|
||||||
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
|
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
|
||||||
icon: Brain
|
icon: Brain
|
||||||
},
|
},
|
||||||
coder: {
|
coder: {
|
||||||
title: "Coden & Entwicklung",
|
title: "Coden & Entwicklung",
|
||||||
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
|
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
|
||||||
icon: Code
|
icon: Code
|
||||||
},
|
},
|
||||||
vision: {
|
vision: {
|
||||||
title: "Bilder & Vision",
|
title: "Bilder & Vision",
|
||||||
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
|
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
|
||||||
icon: Eye
|
icon: Eye
|
||||||
},
|
},
|
||||||
hermes: {
|
hermes: {
|
||||||
title: "Lucys Hirn (Agent)",
|
title: "Lucys Hirn (Agent)",
|
||||||
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
|
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
|
||||||
icon: BrainCircuit
|
icon: BrainCircuit
|
||||||
},
|
},
|
||||||
scout: {
|
scout: {
|
||||||
title: "Multimodal-Allrounder",
|
title: "Multimodal-Allrounder",
|
||||||
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
|
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
|
||||||
icon: Compass
|
icon: Compass
|
||||||
},
|
},
|
||||||
kritiker: {
|
kritiker: {
|
||||||
title: "Kritisches Prüf-Hirn",
|
title: "Kritisches Prüf-Hirn",
|
||||||
desc: "Schärfer Blick für Fehler, Schwachstellen und Sicherheitslücken — denkt skeptisch, bevor du deployst.",
|
desc: "Schärfer Blick für Fehler, Schwachstellen und Sicherheitslücken — denkt skeptisch, bevor du deployst.",
|
||||||
icon: Scale
|
icon: Scale
|
||||||
},
|
},
|
||||||
reranker: {
|
reranker: {
|
||||||
title: "Gedächtnis-Sortierer",
|
title: "Gedächtnis-Sortierer",
|
||||||
desc: "Sortiert und gewichtet gespeicherte Fakten nach Relevanz — damit die richtige Info sofort da ist.",
|
desc: "Sortiert und gewichtet gespeicherte Fakten nach Relevanz — damit die richtige Info sofort da ist.",
|
||||||
icon: Search
|
icon: Search
|
||||||
},
|
},
|
||||||
debugger: {
|
debugger: {
|
||||||
title: "Debugger & Selbstheiler",
|
title: "Debugger & Selbstheiler",
|
||||||
desc: "Agentisches Debugging, multimodale Fehleranalyse und Selbstreparatur (z.B. Muse-Glimmer).",
|
desc: "Agentisches Debugging, multimodale Fehleranalyse und Selbstreparatur (z.B. Muse-Glimmer).",
|
||||||
icon: Brain
|
icon: Brain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Discover() {
|
export function Discover() {
|
||||||
const { data, isLoading: loading, error: loadErr } = useDiscover()
|
const { data, isLoading: loading, error: loadErr } = useDiscover()
|
||||||
const { data: modelsResp } = useModels()
|
const { data: modelsResp } = useModels()
|
||||||
const { data: updates } = useUpdates()
|
const { data: updates } = useUpdates()
|
||||||
const models = modelsResp?.models ?? []
|
const models = modelsResp?.models ?? []
|
||||||
const error = loadErr ? String(loadErr) : ""
|
const error = loadErr ? String(loadErr) : ""
|
||||||
const [installing, setInstalling] = useState<Record<string, string>>({})
|
const [installing, setInstalling] = useState<Record<string, string>>({})
|
||||||
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
|
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
|
||||||
const [mode, setMode] = useState<"recommended" | "browse">("recommended")
|
const [mode, setMode] = useState<"recommended" | "browse">("recommended")
|
||||||
|
|
||||||
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
|
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
|
||||||
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
|
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
|
||||||
try {
|
try {
|
||||||
await api("/api/models/install", {
|
await api("/api/models/install", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
|
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
|
||||||
})
|
})
|
||||||
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
|
// Seit P1 traegt api() den echten Grund vom Backend (ApiError.detail) — den zeigen,
|
||||||
}
|
// statt ihn zu verschlucken und "Fehler" hinzuschreiben.
|
||||||
}
|
setInstalling((s) => ({ ...s, [repo]: `Fehler: ${e instanceof Error ? e.message : String(e)}` }))
|
||||||
|
}
|
||||||
return (
|
}
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
|
return (
|
||||||
<div className="flex mc-card-sm p-1 w-fit">
|
<div className="space-y-6">
|
||||||
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
|
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
|
||||||
<button
|
<div className="flex mc-card-sm p-1 w-fit">
|
||||||
key={m}
|
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
|
||||||
onClick={() => setMode(m)}
|
<button
|
||||||
className={cn(
|
key={m}
|
||||||
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
|
onClick={() => setMode(m)}
|
||||||
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
|
className={cn(
|
||||||
)}
|
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
|
||||||
>
|
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
|
||||||
{label}
|
)}
|
||||||
</button>
|
>
|
||||||
))}
|
{label}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
{mode === "browse" ? (
|
</div>
|
||||||
<ModelBrowse />
|
|
||||||
) : loading ? (
|
{mode === "browse" ? (
|
||||||
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen…</div>
|
<ModelBrowse />
|
||||||
) : (error || !data) ? (
|
) : loading ? (
|
||||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen…</div>
|
||||||
Empfehlungsdienst temporär nicht erreichbar ({error}).
|
) : (error || !data) ? (
|
||||||
</div>
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
||||||
) : (
|
Empfehlungsdienst temporär nicht erreichbar ({error}).
|
||||||
<div className="space-y-8">
|
</div>
|
||||||
{/* 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="space-y-8">
|
||||||
<div>
|
{/* Informational Header */}
|
||||||
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
|
<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>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
|
||||||
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
|
</div>
|
||||||
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
|
<div className="flex items-center gap-1.5">
|
||||||
</div>
|
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
|
||||||
</div>
|
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
|
||||||
|
</div>
|
||||||
{/* Sockets/Slots Grid */}
|
</div>
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
{data.categories.map((cat) => {
|
{/* Sockets/Slots Grid */}
|
||||||
const meta = ROLE_METADATA[cat.role] || {
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
title: cat.title || cat.role,
|
{data.categories.map((cat) => {
|
||||||
desc: "Spezifisches Modell für diese Systemrolle.",
|
const meta = ROLE_METADATA[cat.role] || {
|
||||||
icon: Layers
|
title: cat.title || cat.role,
|
||||||
}
|
desc: "Spezifisches Modell für diese Systemrolle.",
|
||||||
const IconComponent = meta.icon
|
icon: Layers
|
||||||
|
}
|
||||||
// Check if a model is installed for this role — per Rolle ODER Alias:
|
const IconComponent = meta.icon
|
||||||
// ein Modell kann eine Kategorie über einen Alias bedienen (z.B. Qwen3.6 hat
|
|
||||||
// role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
|
// Check if a model is installed for this role — per Rolle ODER Alias:
|
||||||
const installedModel = models.find(
|
// ein Modell kann eine Kategorie über einen Alias bedienen (z.B. Qwen3.6 hat
|
||||||
(m) => m.role === cat.role || (m.aliases || []).includes(cat.role),
|
// role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
|
||||||
)
|
const installedModel = models.find(
|
||||||
|
(m) => m.role === cat.role || (m.aliases || []).includes(cat.role),
|
||||||
// Check if an upgrade is available for this role
|
)
|
||||||
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
|
|
||||||
|
// Check if an upgrade is available for this role
|
||||||
// Get the primary recommended model
|
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
|
||||||
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
|
|
||||||
if (!recommendedModel) return null
|
// Get the primary recommended model
|
||||||
|
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
|
||||||
const isInstallingRecommended = installing[recommendedModel.repo]
|
if (!recommendedModel) return null
|
||||||
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
|
|
||||||
const isExpanded = !!expandedAlternatives[cat.role]
|
const isInstallingRecommended = installing[recommendedModel.repo]
|
||||||
|
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
|
||||||
return (
|
const isExpanded = !!expandedAlternatives[cat.role]
|
||||||
<div
|
|
||||||
key={cat.role}
|
return (
|
||||||
className={cn(
|
<div
|
||||||
"mc-card p-5 flex flex-col justify-between gap-5",
|
key={cat.role}
|
||||||
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
|
className={cn(
|
||||||
)}
|
"mc-card p-5 flex flex-col justify-between gap-5",
|
||||||
>
|
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
|
||||||
<div className="space-y-4">
|
)}
|
||||||
{/* Socket Header */}
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
{/* Socket Header */}
|
||||||
<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">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<IconComponent className="h-5.5 w-5.5" />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<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">
|
||||||
<div>
|
<IconComponent className="h-5.5 w-5.5" />
|
||||||
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
|
</div>
|
||||||
<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">
|
<div>
|
||||||
Rolle: {cat.role}
|
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
|
||||||
</span>
|
<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">
|
||||||
</div>
|
Rolle: {cat.role}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
{/* Status Badges */}
|
</div>
|
||||||
{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">
|
{/* Status Badges */}
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
{installedModel ? (
|
||||||
Aktiviert
|
<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>
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
) : (
|
Aktiviert
|
||||||
<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">
|
</span>
|
||||||
Frei
|
) : (
|
||||||
</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
|
||||||
</div>
|
</span>
|
||||||
|
)}
|
||||||
{/* Role Description */}
|
</div>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
{meta.desc}
|
{/* Role Description */}
|
||||||
</p>
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{meta.desc}
|
||||||
{/* Current vs Recommended model card */}
|
</p>
|
||||||
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
|
|
||||||
{installedModel ? (
|
{/* Current vs Recommended model card */}
|
||||||
<div className="space-y-1.5">
|
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
|
||||||
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
|
{installedModel ? (
|
||||||
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
|
<div className="space-y-1.5">
|
||||||
{installedModel.name.split("/").pop()}
|
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
|
||||||
</div>
|
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
|
||||||
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
|
{installedModel.name.split("/").pop()}
|
||||||
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
|
</div>
|
||||||
<span>•</span>
|
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
|
||||||
<span>Quant: {installedModel.quant || "GGUF"}</span>
|
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
|
||||||
</div>
|
<span>•</span>
|
||||||
</div>
|
<span>Quant: {installedModel.quant || "GGUF"}</span>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-1.5">
|
</div>
|
||||||
<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}>
|
<div className="space-y-1.5">
|
||||||
{recommendedModel.name}
|
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
|
||||||
</div>
|
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
|
||||||
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
|
{recommendedModel.name}
|
||||||
<span>Ersteller: {recommendedModel.author}</span>
|
</div>
|
||||||
<span>•</span>
|
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
|
||||||
<span>Quant: {recommendedModel.quant}</span>
|
<span>Ersteller: {recommendedModel.author}</span>
|
||||||
</div>
|
<span>•</span>
|
||||||
<div className="flex items-center gap-1.5 pt-0.5">
|
<span>Quant: {recommendedModel.quant}</span>
|
||||||
<FitBadge fit={recommendedModel.fit} />
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1.5 pt-0.5">
|
||||||
</div>
|
<FitBadge fit={recommendedModel.fit} />
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Main Action Button */}
|
</div>
|
||||||
<div className="pt-1">
|
|
||||||
{installedModel ? (
|
{/* Main Action Button */}
|
||||||
hasUpgrade ? (
|
<div className="pt-1">
|
||||||
<div className="space-y-2">
|
{installedModel ? (
|
||||||
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
|
hasUpgrade ? (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
|
<div className="space-y-2">
|
||||||
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
|
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
|
||||||
</div>
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
|
||||||
<button
|
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
|
||||||
// Quant des Empfehlungsmodells nur übernehmen, wenn das Upgrade DASSELBE
|
</div>
|
||||||
// Repo ist — sonst neutraler Default (fremde Quant könnte dort fehlen).
|
<button
|
||||||
onClick={() => install(hasUpgrade.repo, cat.role,
|
// Quant des Empfehlungsmodells nur übernehmen, wenn das Upgrade DASSELBE
|
||||||
hasUpgrade.repo === recommendedModel.repo ? (recommendedModel.quant || "Q4_K_M") : "Q4_K_M",
|
// Repo ist — sonst neutraler Default (fremde Quant könnte dort fehlen).
|
||||||
recommendedModel.caps.tools !== "no")}
|
onClick={() => install(hasUpgrade.repo, cat.role,
|
||||||
disabled={!!installing[hasUpgrade.repo]}
|
hasUpgrade.repo === recommendedModel.repo ? (recommendedModel.quant || "Q4_K_M") : "Q4_K_M",
|
||||||
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"
|
recommendedModel.caps.tools !== "no")}
|
||||||
>
|
disabled={!!installing[hasUpgrade.repo]}
|
||||||
<Download className="h-3.5 w-3.5" />
|
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"
|
||||||
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
|
>
|
||||||
</button>
|
<Download className="h-3.5 w-3.5" />
|
||||||
</div>
|
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
|
||||||
) : (
|
</button>
|
||||||
<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">
|
</div>
|
||||||
<Check className="h-4 w-4" /> Auf neuestem Stand
|
) : (
|
||||||
</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
|
)
|
||||||
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
) : (
|
||||||
disabled={!!isInstallingRecommended}
|
<button
|
||||||
className={cn(
|
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
||||||
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
|
disabled={!!isInstallingRecommended}
|
||||||
isInstallingRecommended
|
className={cn(
|
||||||
? "border-primary/40 bg-primary/5 text-primary"
|
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
|
||||||
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
|
isInstallingRecommended
|
||||||
)}
|
? "border-primary/40 bg-primary/5 text-primary"
|
||||||
>
|
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
|
||||||
<Download className="h-3.5 w-3.5" />
|
)}
|
||||||
{isInstallingRecommended || "Optimales Modell einsetzen"}
|
>
|
||||||
</button>
|
<Download className="h-3.5 w-3.5" />
|
||||||
)}
|
{isInstallingRecommended || "Optimales Modell einsetzen"}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
{/* Collapsible alternatives list */}
|
</div>
|
||||||
{alternativeModels.length > 0 && (
|
|
||||||
<div className="border-t border-border/20 pt-3">
|
{/* Collapsible alternatives list */}
|
||||||
<button
|
{alternativeModels.length > 0 && (
|
||||||
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
|
<div className="border-t border-border/20 pt-3">
|
||||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
|
<button
|
||||||
>
|
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
|
||||||
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
|
||||||
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
|
>
|
||||||
</button>
|
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
|
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
|
||||||
{isExpanded && (
|
</button>
|
||||||
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
|
|
||||||
{alternativeModels.map((alt) => (
|
{isExpanded && (
|
||||||
<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="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
|
||||||
<div className="min-w-0">
|
{alternativeModels.map((alt) => (
|
||||||
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
|
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
|
||||||
{alt.name}
|
<div className="min-w-0">
|
||||||
</div>
|
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
|
||||||
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
|
{alt.name}
|
||||||
<span>Quant: {alt.quant}</span>
|
</div>
|
||||||
<span>•</span>
|
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
|
||||||
<span>{alt.fit.text}</span>
|
<span>Quant: {alt.quant}</span>
|
||||||
</div>
|
<span>•</span>
|
||||||
</div>
|
<span>{alt.fit.text}</span>
|
||||||
<button
|
</div>
|
||||||
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
|
</div>
|
||||||
disabled={!!installing[alt.repo]}
|
<button
|
||||||
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"
|
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
|
||||||
>
|
disabled={!!installing[alt.repo]}
|
||||||
{installing[alt.repo] || "Installieren"}
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
{installing[alt.repo] || "Installieren"}
|
||||||
))}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ export function ModelBrowse() {
|
|||||||
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
||||||
setOomArmed(false)
|
setOomArmed(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
|
// Seit P1 traegt api() den echten Grund vom Backend (ApiError.detail) — den zeigen,
|
||||||
|
// statt ihn zu verschlucken und "Fehler" hinzuschreiben.
|
||||||
|
setInstalling((s) => ({ ...s, [repo]: `Fehler: ${e instanceof Error ? e.message : String(e)}` }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["./src/*"] }
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
// Vitest-Globals (describe/it/expect) + jest-dom-Matcher ohne Import je Datei.
|
||||||
|
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: { outDir: "dist" },
|
build: { outDir: "dist" },
|
||||||
|
// Vitest teilt sich diese Konfiguration (Aliase, Plugins) — kein zweiter Build-Aufbau.
|
||||||
|
// jsdom, weil die Tests echte Komponenten mounten; `globals` spart den import in jeder Datei.
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
restoreMocks: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user