feat(stream): v3-Umbau P4 — Messwerte kommen gepusht statt gepollt

Fuenfte Etappe. Der Ereignisstrom war bisher ein reiner Anstoss-Bus, und die
Messwerte holte sich das Frontend im 3-Sekunden-Takt selbst — zwei Dauer-Anfragen,
unabhaengig davon, ob sich etwas geaendert hatte (Befund B-12).

## Backend: /api/stream

routers/events.py bedient jetzt zwei Endpunkte aus EINEM Sammler:

  /api/stream   `invalidate` (bei Aenderung) + `metrik` (jede Sekunde)
  /api/events   nur `invalidate` — bleibt EINE Fassung lang stehen, weil ein
                Browser-Tab nach einem Deploy noch das vorige Buendel halten kann
                und dieses nur /api/events kennt

Dazu services/system.py → metrik_punkt(): ein bewusst LEICHTER Messpunkt.
`system_status()` waere hier falsch — es ruft `psutil.cpu_percent(interval=0.1)`
und blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt 10 % der Zeit),
plus den Versions-Check, den niemand sekuendlich braucht. Gemessen: 0,2 ms je
Punkt mit `interval=None`.

Token stehen als GESAMTZAEHLER im Ereignis, nicht als Rate. So bleibt der Server
zustandslos und ein verpasster Punkt verfaelscht nichts — der Klient rechnet die
Rate aus zwei Punkten.

Neu im Fingerabdruck: Jobs (Zustand + Fortschritt). Damit ist auch der 3-s-Poller
der System-Schublade nur noch Sicherheitsnetz.

## Was bewusst FEHLT

Kein `agent`-Thema fuer Lucys Denkschritte. MC2 kann Hermes' interne Schritte nicht
sehen, ohne dessen Quellcode zu patchen — per AGENTS.md verboten. Eine leere Leitung
zu bauen waere eine Zusage, die keiner einloest. Das betrifft die Agent-Matrix aus
§4.4 der Spezifikation; sie braucht zuerst eine Datenquelle.

## Frontend

lib/events.ts hoert auf /api/stream und schreibt `metrik` direkt in den
Metrik-Speicher. Der bleibt bewusst ein useSyncExternalStore AUSSERHALB von React
(nicht der Zustand-Store aus P3): Bei einem Wert pro Sekunde wuerde ein Store-Update
jede abonnierende Komponente neu rendern.

## Gedrosselt statt abgeschaltet — eine Korrektur am eigenen Entwurf

Der erste Wurf schaltete beide Poller bei stehendem Strom komplett ab (`false`).
Das waere falsch gewesen: Beide Antworten tragen mehr als Messwerte —
/api/system/status die Versions-Hashes fuer den Schienen-Fuss, /api/system/token-stats
die Gesamtsumme und die Cloud-Ersparnis, fuer die das Backend die Tarife aufloest
(die Preis-Logik ist dort die einzige Wahrheit; sie im Klienten nachzubauen waere
eine zweite). Beides waere eingefroren.

Jetzt 3 s → 60 s bei stehendem Strom: ein Zwanzigstel der Last, und die Randdaten
bleiben frisch. Die MESSWERTE selbst kommen aus dem Strom — useSystemHistory legt
den letzten Messpunkt ueber die Query-Antwort, damit Legende, Temperatur und
Betriebszeit nicht zwischen zwei Minuten-Abfragen stehen bleiben.

## Verifiziert

  Server: 12 `metrik`-Ereignisse in den ersten 4 kB des Stroms (1/s)
  Server-Log ueber die ganze Prozesslaufzeit: /api/system/status 3 Anfragen,
    /api/system/token-stats 3 Anfragen — vorher waere das eine je 3 Sekunden gewesen
  Browser: Statusleiste zaehlt live weiter (Speicher 14,6 → 12,9 GB, CPU 3 → 2 %,
    Betriebszeit 1:22 → 1:23) bei NULL fetch-Aufrufen im 49-s-Fenster
  Cockpit: beide Diagramme rendern (2 Container, 5 Flaechen)
  /api/events antwortet weiterhin (Alt-Tab im Log)

Einschraenkung, ehrlich: Die Browser-Pane war waehrend der Messung verborgen, und
TanStack Query pausiert Intervalle in Hintergrund-Tabs. Die Null im Klienten ist
daher KEIN sauberer Beleg fuer die Drosselung — der Server-Log ist es. Nebenbefund:
Der Strom laeuft auch im Hintergrund-Tab weiter, die Poller nicht.

37/37 Tests gruen (4 neue fuer pushMetrik: Ratenbildung, Zaehler-Ruecksprung,
letzter Messpunkt; MAX_POINTS ist jetzt exportiert, damit der Deckel-Test nicht
wieder gegen eine veraltete Kopie prueft) · ESLint 0 Fehler · Einstieg 118 582 B
gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:16:29 +02:00
co-authored by Claude Opus 5
parent 5aa5a484a7
commit b74e98ffbb
40 changed files with 663 additions and 379 deletions
+85 -37
View File
@@ -1,25 +1,30 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt. """Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden Zwei Endpunkte, ein Sammler:
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare), GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus · `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token- · `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern nur `/api/events` kennt. Danach entfernen.
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
Sekunden), Modell-Quelle aus der Karte übernommen.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource `/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
selbst und bis dahin pollt die UI wie bisher). sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
Frontend-Gegenstück: frontend/src/lib/events.ts
""" """
import asyncio import asyncio
@@ -35,7 +40,8 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen) METRIK_S = 1.0 # Takt der Messpunkte
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
@@ -66,6 +72,13 @@ def _fingerprints() -> dict[str, object]:
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models())) fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception: except Exception:
pass pass
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
from services import jobengine
fp["jobs"] = json.dumps(
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs()]
)
except Exception:
pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes # Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"), fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json")) _mtime(MODELS_DIR / "mc2-announce-branches.json"))
@@ -73,28 +86,63 @@ def _fingerprints() -> dict[str, object]:
return fp return fp
@router.get("/events") async def _strom(request: Request, mit_metrik: bool):
async def events(request: Request) -> StreamingResponse: """Gemeinsamer Kern beider Endpunkte.
async def strom():
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) — Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken. ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints() """
yield ": verbunden\n\n" alt = _fingerprints()
seit_ping = 0.0 yield ": verbunden\n\n"
while True:
if await request.is_disconnected(): seit_abdruck = 0.0
return seit_ping = 0.0
await asyncio.sleep(TICK_S) takt = METRIK_S if mit_metrik else ABDRUCK_S
seit_ping += TICK_S
while True:
if await request.is_disconnected():
return
await asyncio.sleep(takt)
seit_abdruck += takt
seit_ping += takt
if mit_metrik:
try:
from services.system import metrik_punkt
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
seit_ping = 0.0
except Exception:
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
if seit_abdruck >= ABDRUCK_S:
seit_abdruck = 0.0
neu = _fingerprints() neu = _fingerprints()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]] keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu) alt.update(neu)
if keys: if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n" yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0 seit_ping = 0.0
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
return StreamingResponse(strom(), media_type="text/event-stream", if seit_ping >= KEEPALIVE_S:
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) yield ": ping\n\n"
seit_ping = 0.0
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
"""Der aktuelle Kanal: Anstöße UND Messpunkte."""
return StreamingResponse(_strom(request, mit_metrik=True),
media_type="text/event-stream", headers=_KOPF)
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
"""Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor
dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen."""
return StreamingResponse(_strom(request, mit_metrik=False),
media_type="text/event-stream", headers=_KOPF)
+41
View File
@@ -206,6 +206,47 @@ def system_status() -> dict:
} }
def metrik_punkt() -> dict:
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
verfaelscht nichts."""
vm = psutil.virtual_memory()
temp = _temps() or {}
gpu = _gpu_sysfs() or {}
try:
from services.token_stats import get_stats
tok = get_stats()
except Exception:
tok = {}
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
disk = None
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.percent,
"ram_used": vm.used,
"ram_total": vm.total,
"gpu": gpu.get("busy_percent"),
"disk": disk,
"temp_cpu": temp.get("cpu"),
"temp_gpu": temp.get("gpu"),
"uptime_s": _uptime_s(),
"tok_p": tok.get("prompt_tokens", 0),
"tok_c": tok.get("completion_tokens", 0),
}
def _uptime_s() -> int | None: def _uptime_s() -> int | None:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts """Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
liefert — eine erfundene Zahl waere schlimmer als eine fehlende.""" liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
@@ -1,4 +1,4 @@
import{c as A,z as M,M as B,f as z,k as D,u as T,r as W,j as e,b as a,N as g,O as b,P as G,t as f,W as j,X as I,C as N,n as y,q as i}from"./index-CB2Jz083.js";import{E as q}from"./external-link-CeXCWq9C.js";import{S as U}from"./shield-C1oLnNqm.js";/** import{c as A,z as M,M as B,f as z,k as D,u as T,r as W,j as e,b as a,N as g,O as b,P as G,t as f,W as j,X as I,C as N,n as y,q as i}from"./index-Cx7RCLVH.js";import{E as q}from"./external-link-CgE4twbA.js";import{S as U}from"./shield-BEuf-cEX.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,4 +1,4 @@
import{c as W,z as G,a1 as H,a2 as T,u as F,k as _,r as u,j as e,b as N,L as m,C as E,_ as O,a0 as z,a3 as R,X as I,T as B,G as V,n as K,a4 as P,q}from"./index-CB2Jz083.js";import{S,H as U}from"./SectionLabel-Dvaa0NeT.js";import{R as Q}from"./refresh-cw-BaiqRBZd.js";import{A as Z}from"./arrow-right-C2xQ80N8.js";import{C as $}from"./clock-BFJQZ7fi.js";import{C as M}from"./chevron-down-BVWIvMwj.js";/** import{c as W,z as G,a1 as H,a2 as T,u as F,k as _,r as u,j as e,b as N,L as m,C as E,_ as O,a0 as z,a3 as R,X as I,T as B,G as V,n as K,a4 as P,q}from"./index-Cx7RCLVH.js";import{S,H as U}from"./SectionLabel-DUeBkmth.js";import{R as Q}from"./refresh-cw-C02wnoZb.js";import{A as Z}from"./arrow-right-CBVuzArl.js";import{C as $}from"./clock-DF_nrSqC.js";import{C as M}from"./chevron-down-IhuTdfx8.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,4 +1,4 @@
import{c as d,ah as S,r as h,j as e,ai as M,L as p,a0 as z,R as j,a4 as C,aj as D,ak as A,b as B,al as Z,u as E,k as L,n as k,q}from"./index-CB2Jz083.js";/** import{c as d,ah as S,r as h,j as e,ai as M,L as p,a0 as z,R as j,a4 as C,aj as D,ak as A,b as B,al as Z,u as E,k as L,n as k,q}from"./index-Cx7RCLVH.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.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as f,r as m,D as G,F as H,j as e,t as I,B as M,I as y,G as z,b as S,L as B,J as _,K as E,C as P}from"./index-CB2Jz083.js";import{A as u}from"./arrow-right-C2xQ80N8.js";import{C as K}from"./chevron-down-BVWIvMwj.js";/** import{c as f,r as m,D as G,F as H,j as e,t as I,B as M,I as y,G as z,b as S,L as B,J as _,K as E,C as P}from"./index-Cx7RCLVH.js";import{A as u}from"./arrow-right-CBVuzArl.js";import{C as K}from"./chevron-down-IhuTdfx8.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,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,O as v,_ as L,$ as G,W as D,b as f,a0 as T}from"./index-CB2Jz083.js";import{a as w,L as B,C as P,Z as E}from"./zap-CZWNuZRx.js";import{L as R}from"./layers-kjHs8USe.js";import{S as I}from"./shield-C1oLnNqm.js";/** import{c as u,j as e,r as M,t as C,V as z,Y as k,Z as y,O as v,_ as L,$ as G,W as D,b as f,a0 as T}from"./index-Cx7RCLVH.js";import{a as w,L as B,C as P,Z as E}from"./zap-CDvJ9oJD.js";import{L as R}from"./layers-DUCYNtcW.js";import{S as I}from"./shield-BEuf-cEX.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,4 +1,4 @@
import{c as $,j as e,b as S,a2 as Se,u as De,k as Ce,r as u,z as Ke,A as ze,n as z,a5 as Ee,_ as O,L as D,X as Q,a6 as xe,G as T,C as Ae,T as Le,a3 as Pe,R as We,q as Re}from"./index-CB2Jz083.js";import{S as $e,H as X}from"./SectionLabel-Dvaa0NeT.js";import{C as ee}from"./code-xml-CnLWZOS5.js";import{C as he}from"./clock-BFJQZ7fi.js";import{L as Be}from"./layers-kjHs8USe.js";import{C as U}from"./chevron-down-BVWIvMwj.js";import{F as ge}from"./file-text-jLFOD20t.js";import{R as fe}from"./refresh-cw-BaiqRBZd.js";/** import{c as $,j as e,b as S,a2 as Se,u as De,k as Ce,r as u,z as Ke,A as ze,n as z,a5 as Ee,_ as O,L as D,X as Q,a6 as xe,G as T,C as Ae,T as Le,a3 as Pe,R as We,q as Re}from"./index-Cx7RCLVH.js";import{S as $e,H as X}from"./SectionLabel-DUeBkmth.js";import{C as ee}from"./code-xml-B-fOQx_u.js";import{C as he}from"./clock-DF_nrSqC.js";import{L as Be}from"./layers-DUCYNtcW.js";import{C as U}from"./chevron-down-IhuTdfx8.js";import{F as ge}from"./file-text-DCWu7Fnn.js";import{R as fe}from"./refresh-cw-C02wnoZb.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 @@
import{u,af as m,k as x,j as s,y as n,ag as b,b as p,n as f,q as h}from"./index-CB2Jz083.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,af as m,k as x,j as s,y as n,ag as b,b as p,n as f,q as h}from"./index-Cx7RCLVH.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};
@@ -1 +1 @@
import{M as h,u as g,N as p,r as d,j as e,b as x,T as j,S as N,n as v,Q as m,U as w,q as f}from"./index-CB2Jz083.js";import{E as k}from"./external-link-CeXCWq9C.js";import{R as y}from"./refresh-cw-BaiqRBZd.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!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:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,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(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?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:[n===!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(j,{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:b,disabled:o,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(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,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{K as KonsoleView}; import{M as h,u as g,N as p,r as d,j as e,b as x,T as j,S as N,n as v,Q as m,U as w,q as f}from"./index-Cx7RCLVH.js";import{E as k}from"./external-link-CgE4twbA.js";import{R as y}from"./refresh-cw-C02wnoZb.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!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:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,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(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?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:[n===!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(j,{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:b,disabled:o,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(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,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{K as KonsoleView};
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as E,u as he,a as qe,r as v,j as e,C as Y,L as Fe,b as k,d as Re,q as V,R as Ie,B as re,e as Pe,E as ze,f as le,g as Be,h as Ae,i as Oe,k as Ge,l as ee,X as xe,H as be,T as se,s as Te,m as He,n as q,o as U,p as Qe,t as Ue,v as Ve,w as We,x as Ze,y as Je,z as Xe,A as Ye}from"./index-CB2Jz083.js";import{J as et}from"./JobsBar-CWvPMGpi.js";import{C as tt}from"./code-xml-CnLWZOS5.js";import{C as ue,Z as ie,L as rt,a as st}from"./zap-CZWNuZRx.js";import{S as ge}from"./search-DIWv8lX1.js";import{P as nt}from"./power-ByoRBdY5.js";import{C as $e}from"./chevron-down-BVWIvMwj.js";import{L as at}from"./layers-kjHs8USe.js";/** import{c as E,u as he,a as qe,r as v,j as e,C as Y,L as Fe,b as k,d as Re,q as V,R as Ie,B as re,e as Pe,E as ze,f as le,g as Be,h as Ae,i as Oe,k as Ge,l as ee,X as xe,H as be,T as se,s as Te,m as He,n as q,o as U,p as Qe,t as Ue,v as Ve,w as We,x as Ze,y as Je,z as Xe,A as Ye}from"./index-Cx7RCLVH.js";import{J as et}from"./JobsBar-DJ6T2S2l.js";import{C as tt}from"./code-xml-B-fOQx_u.js";import{C as ue,Z as ie,L as rt,a as st}from"./zap-CDvJ9oJD.js";import{S as ge}from"./search-CPzoxwoE.js";import{P as nt}from"./power-wqc9-u1c.js";import{C as $e}from"./chevron-down-IhuTdfx8.js";import{L as at}from"./layers-DUCYNtcW.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,4 +1,4 @@
import{c as s,j as e}from"./index-CB2Jz083.js";/** import{c as s,j as e}from"./index-Cx7RCLVH.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,4 +1,4 @@
var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("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)?E("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{a7 as A,a8 as B,a9 as M,aa as F,ab as P,u as J,r as f,ac as I,ad as Q,c as T,ae as U,j as s,J as D,T as H,b as K,L,O as V,q as _,n as R}from"./index-CB2Jz083.js";import{J as z}from"./JobsBar-CWvPMGpi.js";var c,g,o,h,m,w,C,q,G=(q=class extends A{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),B(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(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)??F();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=J(),[n]=f.useState(()=>new G(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(I)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/** var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("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)?E("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{a7 as A,a8 as B,a9 as M,aa as F,ab as P,u as J,r as f,ac as I,ad as Q,c as T,ae as U,j as s,J as D,T as H,b as K,L,O as V,q as _,n as R}from"./index-Cx7RCLVH.js";import{J as z}from"./JobsBar-DJ6T2S2l.js";var c,g,o,h,m,w,C,q,G=(q=class extends A{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),B(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(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)??F();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=J(),[n]=f.useState(()=>new G(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(I)},[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.
@@ -1,4 +1,4 @@
import{c as w,j as e,b as c,r as l,T as W,L as ae,E as ve,n as b,O as le,V as oe,X as de,J as Se,u as Ce,k as _e,x as ze,ar as Fe,af as Le,al as Ue,U as P,t as Ae,y as q,q as N}from"./index-CB2Jz083.js";import{R as F}from"./refresh-cw-BaiqRBZd.js";import{F as Pe}from"./file-text-jLFOD20t.js";import{S as Be}from"./search-DIWv8lX1.js";import{S as ce}from"./shield-C1oLnNqm.js";import{A as ne}from"./arrow-right-C2xQ80N8.js";import{C as De}from"./clock-BFJQZ7fi.js";import{E as Re}from"./external-link-CeXCWq9C.js";import{P as $e}from"./power-ByoRBdY5.js";/** import{c as w,j as e,b as c,r as l,T as W,L as ae,E as ve,n as b,O as le,V as oe,X as de,J as Se,u as Ce,k as _e,x as ze,ar as Fe,af as Le,al as Ue,U as P,t as Ae,y as q,q as N}from"./index-Cx7RCLVH.js";import{R as F}from"./refresh-cw-C02wnoZb.js";import{F as Pe}from"./file-text-DCWu7Fnn.js";import{S as Be}from"./search-CPzoxwoE.js";import{S as ce}from"./shield-BEuf-cEX.js";import{A as ne}from"./arrow-right-CBVuzArl.js";import{C as De}from"./clock-DF_nrSqC.js";import{E as Re}from"./external-link-CgE4twbA.js";import{P as $e}from"./power-wqc9-u1c.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,4 +1,4 @@
var to=Object.defineProperty;var eo=(e,n,r)=>n in e?to(e,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[n]=r;var Bn=(e,n,r)=>eo(e,typeof n!="symbol"?n+"":n,r);import{c as no,r as W,am as ro,an as io,j as $,ao as oo,ap as ao,n as so,b as Ae,L as Ke,a0 as uo,aq as lo}from"./index-CB2Jz083.js";import{i as Ft,c as Xn,a as Yn,b as co,o as fo,m as Zn,d as Kn}from"./string-DoZi9Vij.js";import{R as ho}from"./refresh-cw-BaiqRBZd.js";import{S as po}from"./search-DIWv8lX1.js";import{F as go}from"./file-text-jLFOD20t.js";/** var to=Object.defineProperty;var eo=(e,n,r)=>n in e?to(e,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[n]=r;var Bn=(e,n,r)=>eo(e,typeof n!="symbol"?n+"":n,r);import{c as no,r as W,am as ro,an as io,j as $,ao as oo,ap as ao,n as so,b as Ae,L as Ke,a0 as uo,aq as lo}from"./index-Cx7RCLVH.js";import{i as Ft,c as Xn,a as Yn,b as co,o as fo,m as Zn,d as Kn}from"./string-DoZi9Vij.js";import{R as ho}from"./refresh-cw-C02wnoZb.js";import{S as po}from"./search-CPzoxwoE.js";import{F as go}from"./file-text-DCWu7Fnn.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,4 +1,4 @@
import{c as r}from"./index-CB2Jz083.js";/** import{c as r}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as o}from"./index-CB2Jz083.js";/** import{c as o}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c}from"./index-CB2Jz083.js";/** import{c}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as e}from"./index-CB2Jz083.js";/** import{c as e}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as a}from"./index-CB2Jz083.js";/** import{c as a}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as e}from"./index-CB2Jz083.js";/** import{c as e}from"./index-Cx7RCLVH.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
View File
@@ -1 +0,0 @@
import{ax as r}from"./index-CB2Jz083.js";var o=r();export{o as r};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{ax as r}from"./index-Cx7RCLVH.js";var o=r();export{o as r};
@@ -1,4 +1,4 @@
import{c as a}from"./index-CB2Jz083.js";/** import{c as a}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as o}from"./index-CB2Jz083.js";/** import{c as o}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as e}from"./index-CB2Jz083.js";/** import{c as e}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c}from"./index-CB2Jz083.js";/** import{c}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as a}from"./index-CB2Jz083.js";/** import{c as a}from"./index-Cx7RCLVH.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,4 +1,4 @@
import{c as a}from"./index-CB2Jz083.js";/** import{c as a}from"./index-Cx7RCLVH.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
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CB2Jz083.js"></script> <script type="module" crossorigin src="/assets/index-Cx7RCLVH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQUMn05v.css"> <link rel="stylesheet" crossorigin href="/assets/index-DQUMn05v.css">
</head> </head>
<body> <body>
+16 -9
View File
@@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useHealth, useRouting, useSystemStatus, useTokenStats } from "@/lib/queries" import { useHealth, useRouting, useSystemStatus, useTokenStats } from "@/lib/queries"
import { useSysHistory, useTokHistory } from "@/lib/metricsStore" import { useLetzteMetrik, useSysHistory, useTokHistory } from "@/lib/metricsStore"
import { useStromLage } from "@/app/store" import { useStromLage } from "@/app/store"
import { gb } from "@/lib/format" import { gb } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -80,10 +80,17 @@ export function Statusleiste() {
// (Merksatz aus dem Projekt: Rollen-Aliase statt Modellnamen.) // (Merksatz aus dem Projekt: Rollen-Aliase statt Modellnamen.)
const aktivesModell = health?.brain?.role ?? routing?.lanes?.[0]?.name ?? null const aktivesModell = health?.brain?.role ?? routing?.lanes?.[0]?.name ?? null
const ramProzent = sys?.ram?.percent ?? 0 // Live-Werte kommen aus dem Strom (jede Sekunde); die Query ist nur der Stand vom
const tempMax = Math.max(sys?.temp?.cpu ?? 0, sys?.temp?.gpu ?? 0) // Seitenaufbau und der Rückfall, wenn der Strom gerissen ist (v3-Umbau P4).
const live = useLetzteMetrik()
const cpuProzent = live?.cpu ?? sys?.cpu?.percent ?? 0
const gpuProzent = live?.gpu ?? sys?.gpu?.busy_percent ?? null
const ramProzent = live?.ram ?? sys?.ram?.percent ?? 0
const ramBelegt = live?.ram_used ?? sys?.ram?.used
const ramGesamt = live?.ram_total ?? sys?.ram?.total
const tempMax = Math.max(live?.temp_cpu ?? sys?.temp?.cpu ?? 0, live?.temp_gpu ?? sys?.temp?.gpu ?? 0)
const durchsatz = tokVerlauf.length ? tokVerlauf[tokVerlauf.length - 1].completion : 0 const durchsatz = tokVerlauf.length ? tokVerlauf[tokVerlauf.length - 1].completion : 0
const zeit = betriebszeit(sys?.uptime_s) const zeit = betriebszeit(live?.uptime_s ?? sys?.uptime_s)
const LAGE_STIL: Record<typeof lage, { punkt: string; text: string; titel: string }> = { const LAGE_STIL: Record<typeof lage, { punkt: string; text: string; titel: string }> = {
live: { punkt: "bg-emerald-500", text: "Live", titel: "Ereignisstrom steht — Änderungen erscheinen sofort." }, live: { punkt: "bg-emerald-500", text: "Live", titel: "Ereignisstrom steht — Änderungen erscheinen sofort." },
@@ -117,7 +124,7 @@ export function Statusleiste() {
</Feld> </Feld>
{/* Geteilter Speicher — die Kernzahl dieser Box, deshalb als Balken. */} {/* Geteilter Speicher — die Kernzahl dieser Box, deshalb als Balken. */}
<Feld titel={sys ? `Geteilter Speicher (Unified Memory): ${gb(sys.ram.used)} von ${gb(sys.ram.total)} GB belegt` : "Speicher"}> <Feld titel={ramBelegt != null && ramGesamt != null ? `Geteilter Speicher (Unified Memory): ${gb(ramBelegt)} von ${gb(ramGesamt)} GB belegt` : "Speicher"}>
<span className="text-muted-foreground">Speicher</span> <span className="text-muted-foreground">Speicher</span>
<div className="h-2 w-24 overflow-hidden rounded-sm border border-border/50 bg-background/60" aria-hidden="true"> <div className="h-2 w-24 overflow-hidden rounded-sm border border-border/50 bg-background/60" aria-hidden="true">
<div <div
@@ -127,19 +134,19 @@ export function Statusleiste() {
/> />
</div> </div>
<span className="tabular-nums text-foreground"> <span className="tabular-nums text-foreground">
{sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} GB` : "—"} {ramBelegt != null && ramGesamt != null ? `${gb(ramBelegt)}/${gb(ramGesamt)} GB` : "—"}
</span> </span>
</Feld> </Feld>
{/* Auslastung */} {/* Auslastung */}
<Feld titel="Auslastung von Prozessor und Grafikeinheit"> <Feld titel="Auslastung von Prozessor und Grafikeinheit">
<span className="text-muted-foreground">CPU</span> <span className="text-muted-foreground">CPU</span>
<span className="tabular-nums text-foreground">{Math.round(sys?.cpu?.percent ?? 0)} %</span> <span className="tabular-nums text-foreground">{Math.round(cpuProzent)} %</span>
<Sparkline werte={sysVerlauf.map((p) => p.cpu)} farbe="hsl(199 89% 58%)" /> <Sparkline werte={sysVerlauf.map((p) => p.cpu)} farbe="hsl(199 89% 58%)" />
{sys?.gpu?.busy_percent != null && ( {gpuProzent != null && (
<> <>
<span className="ml-1 text-muted-foreground">GPU</span> <span className="ml-1 text-muted-foreground">GPU</span>
<span className="tabular-nums text-foreground">{Math.round(sys.gpu.busy_percent)} %</span> <span className="tabular-nums text-foreground">{Math.round(gpuProzent)} %</span>
</> </>
)} )}
</Feld> </Feld>
+40 -14
View File
@@ -1,31 +1,51 @@
// SSE-Eventstrom — Gegenstück zu backend/routers/events.py. // Ereignisstrom — Gegenstück zu backend/routers/events.py.
// //
// EIN EventSource auf /api/events; `invalidate`-Events stoßen gezielt die React-Query- // EIN EventSource auf /api/stream. Zwei Ereignisarten:
// Caches an. Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz // · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
// (queries.ts entspannt sie ×5 über sseVerbunden()). Reißt der Strom (MC2-Deploy, // · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
// Netz), reconnectet EventSource von selbst — bis dahin pollt die UI wie früher.
// //
// v3-Umbau P3 (28.08.2026): Der Verbindungszustand wandert zusätzlich in den Store, // Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
// damit die Statusleiste ihn ANZEIGEN kann. Vorher war er eine Modul-Variable, die nur // sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
// queries.ts las — die Oberfläche alterte bei gerissenem Strom still weiter (Befund // pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
// B-15). Jetzt steht dort „Live", „Nachlauf" oder „Getrennt", und man weiß, worauf man
// gerade schaut.
// //
// Ebenfalls P3 (Befund B-18): Beim Wechsel des Zustands wird EINMAL invalidiert. Vorher // v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
// las `relax()` den Zustand erst beim nächsten Refetch — nach einem Riss blieb die UI // `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
// bis zu einem vollen entspannten Takt (bis 150 s) im langsamen Modus. // unabhängig davon, ob sich etwas geändert hatte. Beide Poller sind ersatzlos weg,
// solange der Strom steht.
//
// WICHTIG (der Grund, warum der Metrik-Speicher kein Store ist): `metrik` feuert jede
// Sekunde. Der Speicher liegt bewusst AUSSERHALB von React (lib/metricsStore.ts,
// useSyncExternalStore) — so rendern nur die Karten neu, die den Verlauf wirklich
// abonnieren, nicht die ganze Shell.
import { useEffect } from "react" import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { melden, stromEreignis, stromLageSetzen } from "@/app/store" import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
import { pushMetrik } from "@/lib/metricsStore"
let verbunden = false let verbunden = false
export const sseVerbunden = () => verbunden export const sseVerbunden = () => verbunden
/** Rohform eines `metrik`-Ereignisses (siehe services/system.py → metrik_punkt). */
export interface MetrikPunkt {
cpu: number
ram: number
ram_used: number
ram_total: number
gpu: number | null
disk: number | null
temp_cpu: number | null
temp_gpu: number | null
uptime_s: number | null
/** GESAMTZÄHLER, keine Rate — die rechnet der Speicher aus zwei Punkten selbst. */
tok_p: number
tok_c: number
}
export function useEventStream() { export function useEventStream() {
const qc = useQueryClient() const qc = useQueryClient()
useEffect(() => { useEffect(() => {
const es = new EventSource("/api/events") const es = new EventSource("/api/stream")
// Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst // Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst
// begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der // begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der
// Übergang: einmal beim Reißen, einmal beim Heilen. // Übergang: einmal beim Reißen, einmal beim Heilen.
@@ -60,6 +80,12 @@ export function useEventStream() {
} catch { /* kaputtes Event bricht den Strom nicht */ } } catch { /* kaputtes Event bricht den Strom nicht */ }
}) })
es.addEventListener("metrik", (e) => {
try {
pushMetrik(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch { /* ein kaputter Messpunkt darf den Strom nicht reißen */ }
})
return () => { return () => {
verbunden = false verbunden = false
stromLageSetzen("getrennt") stromLageSetzen("getrennt")
+74 -13
View File
@@ -1,13 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
import { act, renderHook } from "@testing-library/react" import { act, renderHook } from "@testing-library/react"
import type { MetrikPunkt } from "./events"
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel ueberleben). Fuer jeden Test // Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Faelle gegenseitig Daten ein. // eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
async function frischerStore() { async function frischerStore() {
vi.resetModules() vi.resetModules()
return await import("./metricsStore") return await import("./metricsStore")
} }
function punkt(p: Partial<MetrikPunkt> = {}): MetrikPunkt {
return {
cpu: 10, ram: 20, ram_used: 1, ram_total: 2, gpu: null, disk: null,
temp_cpu: null, temp_gpu: null, uptime_s: 100, tok_p: 0, tok_c: 0, ...p,
}
}
describe("metricsStore", () => { describe("metricsStore", () => {
let store: Awaited<ReturnType<typeof frischerStore>> let store: Awaited<ReturnType<typeof frischerStore>>
@@ -27,19 +35,20 @@ describe("metricsStore", () => {
expect(result.current[0].cpu).toBe(10) expect(result.current[0].cpu).toBe(10)
}) })
// Ohne Deckel waechst der Verlauf unbegrenzt — bei 3-s-Takt sind das ~28 800 Punkte // Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
// pro Tag in einem Dauerlaeufer-Tab. // pro Tag in einem Dauerläufer-Tab.
it("deckelt den Verlauf und behaelt die JUENGSTEN Punkte", () => { it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
const { result } = renderHook(() => store.useSysHistory()) const { result } = renderHook(() => store.useSysHistory())
const n = store.MAX_POINTS + 20
act(() => { act(() => {
for (let i = 0; i < 60; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null }) for (let i = 0; i < n; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
}) })
expect(result.current).toHaveLength(40) expect(result.current).toHaveLength(store.MAX_POINTS)
expect(result.current[0].t).toBe(20) expect(result.current[0].t).toBe(n - store.MAX_POINTS)
expect(result.current[39].t).toBe(59) expect(result.current[store.MAX_POINTS - 1].t).toBe(n - 1)
}) })
it("haelt System- und Token-Verlauf auseinander", () => { it("hält System- und Token-Verlauf auseinander", () => {
const { result: sys } = renderHook(() => store.useSysHistory()) const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory()) const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 })) act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
@@ -48,12 +57,64 @@ describe("metricsStore", () => {
expect(tok.current[0].completion).toBe(7) expect(tok.current[0].completion).toBe(7)
}) })
// useSyncExternalStore vergleicht per Referenz: wuerde pushSys das Array an Ort und // useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
// Stelle veraendern, bliebe die Anzeige stehen, obwohl Daten da sind. // Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Aenderung ein NEUES Array (Referenzwechsel)", () => { it("liefert bei jeder Änderung ein NEUES Array (Referenzwechsel)", () => {
const { result } = renderHook(() => store.useSysHistory()) const { result } = renderHook(() => store.useSysHistory())
const vorher = result.current const vorher = result.current
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null })) act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
expect(result.current).not.toBe(vorher) expect(result.current).not.toBe(vorher)
}) })
}) })
// ── Der Einstieg des Ereignisstroms (v3-Umbau P4) ───────────────────────────
// Der Server schickt Token-GESAMTZÄHLER, keine Raten — die Umrechnung passiert hier.
// Genau dort steckt die Fehleranfälligkeit: ein Backend-Neustart setzt die Zähler
// zurück, und aus einem Rückwärtssprung darf keine negative Rate werden.
describe("pushMetrik (Strom-Einstieg)", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
vi.useFakeTimers()
})
it("schreibt beim ersten Punkt NOCH KEINE Rate — dafür fehlt der Vorgänger", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
expect(sys.current).toHaveLength(1)
expect(tok.current).toHaveLength(0)
})
it("rechnet aus zwei Zählerständen eine Rate pro Sekunde", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
act(() => {
vi.advanceTimersByTime(2_000)
store.pushMetrik(punkt({ tok_p: 300, tok_c: 90 }))
})
expect(result.current).toHaveLength(1)
expect(result.current[0].prompt).toBeCloseTo(100, 1) // 200 Token in 2 s
expect(result.current[0].completion).toBeCloseTo(20, 1) // 40 Token in 2 s
})
it("macht aus einem Zähler-Rücksprung keine negative Rate", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 5_000, tok_c: 5_000 })))
act(() => {
vi.advanceTimersByTime(1_000)
store.pushMetrik(punkt({ tok_p: 0, tok_c: 0 })) // Backend neu gestartet
})
expect(result.current[0].prompt).toBe(0)
expect(result.current[0].completion).toBe(0)
})
it("hält den letzten Messpunkt für die Statusleiste bereit", () => {
const { result } = renderHook(() => store.useLetzteMetrik())
expect(result.current).toBeNull()
act(() => store.pushMetrik(punkt({ uptime_s: 4_242, temp_cpu: 71 })))
expect(result.current?.uptime_s).toBe(4_242)
expect(result.current?.temp_cpu).toBe(71)
})
})
+63 -9
View File
@@ -1,12 +1,23 @@
import { useEffect, useRef, useSyncExternalStore } from "react" import { useEffect, useRef, useSyncExternalStore } from "react"
import { useSystemStatus, useTokenStats } from "@/lib/queries" import { useSystemStatus, useTokenStats } from "@/lib/queries"
import type { MetrikPunkt } from "@/lib/events"
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null } export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
export type RateSample = { t: number; prompt: number; completion: number } export type RateSample = { t: number; prompt: number; completion: number }
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Live-Verlauf // v3-Umbau P4: Der Strom liefert jede Sekunde einen Punkt statt alle drei. Bei gleicher
// Fenstergröße (~2 Min Live-Verlauf) heißt das dreimal so viele Punkte.
// Exportiert, damit der Test gegen DIESEN Wert prüft und nicht gegen eine Kopie, die
// beim nächsten Takt-Wechsel still danebenliegt.
export const MAX_POINTS = 120
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer. // Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
//
// Bewusst KEIN Zustand-Store (v3-Umbau P3/P4): Hier landet jede Sekunde ein Wert. Ein
// Store-Update pro Sekunde würde jede abonnierende Komponente neu rendern; mit einem
// eigenen `useSyncExternalStore` außerhalb von React rendern nur die Karten neu, die den
// Verlauf wirklich lesen. Das ist der Grund, warum diese Datei nicht in app/store.ts
// aufgegangen ist.
let sysHist: SysSample[] = [] let sysHist: SysSample[] = []
let tokHist: RateSample[] = [] let tokHist: RateSample[] = []
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
@@ -20,11 +31,50 @@ function subscribe(l: () => void) {
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() } export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() } export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
// Der ZULETZT empfangene Messpunkt. Warum eigens gehalten: Seit P4 ist der Poller von
// /api/system/status abgeschaltet, solange der Strom steht — die Query liefert dann nur
// noch den Stand vom Seitenaufbau. Ohne diesen Wert froeren Betriebszeit, Temperatur und
// die aktuellen Prozentzahlen bei der ersten gemessenen Sekunde ein.
let letzteMetrik: MetrikPunkt | null = null
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist) export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist) export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
/** Immer-laufender Sammler (in App eingehängt, also stets gemountet): füttert den // Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
* Store bei jedem Poll — unabhängig davon, welcher Tab gerade sichtbar ist. */ // Zähler statt Raten — so ist er zustandslos und ein verpasster Punkt verfälscht nichts.
let letzteToken: { p: number; c: number; t: number } | null = null
/** Einstieg für den Ereignisstrom (lib/events.ts). Ein Messpunkt → beide Verläufe. */
export function pushMetrik(m: MetrikPunkt) {
const jetzt = Date.now()
letzteMetrik = m
pushSys({ t: jetzt, cpu: m.cpu ?? 0, ram: m.ram ?? 0, gpu: m.gpu, disk: m.disk })
if (letzteToken) {
const dt = Math.max((jetzt - letzteToken.t) / 1000, 0.001)
// Nie negativ: Startet das Backend neu, springen die Zähler zurück — daraus eine
// negative Rate zu zeichnen wäre schlicht falsch.
pushTok({
t: jetzt,
prompt: Math.max(0, (m.tok_p - letzteToken.p) / dt),
completion: Math.max(0, (m.tok_c - letzteToken.c) / dt),
})
}
letzteToken = { p: m.tok_p, c: m.tok_c, t: jetzt }
}
/** Sammler für den Rückfall-Betrieb (in der Shell eingehängt, also stets gemountet).
*
* Seit P4 kommen die Messpunkte über den Strom. Dieser Sammler füttert den Verlauf nur
* noch aus den POLLERN — also genau dann, wenn der Strom gerissen ist und die Queries
* wieder im Normaltakt nachfragen. Ohne ihn stünden die Graphen bei einem Ausfall still,
* obwohl die Daten da wären.
*
* Doppelte Punkte kann es nicht geben: Steht der Strom, sind die Poller entspannt und
* liefern kaum neue Zeitstempel; der Filter unten wirft alles weg, was jünger als eine
* Sekunde nach dem letzten Punkt ist.
*/
export function useMetricsFeeder() { export function useMetricsFeeder() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus() const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats() const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
@@ -32,8 +82,10 @@ export function useMetricsFeeder() {
useEffect(() => { useEffect(() => {
if (!sys) return if (!sys) return
const jetzt = Date.now()
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
pushSys({ pushSys({
t: Date.now(), t: jetzt,
cpu: sys.cpu?.percent ?? 0, cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0, ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null, gpu: sys.gpu?.busy_percent ?? null,
@@ -48,11 +100,13 @@ export function useMetricsFeeder() {
const p = ts.prompt_tokens, c = ts.completion_tokens const p = ts.prompt_tokens, c = ts.completion_tokens
if (prevTok.current) { if (prevTok.current) {
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001) const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
pushTok({ if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
t: now, pushTok({
prompt: Math.max(0, (p - prevTok.current.p) / dt), t: now,
completion: Math.max(0, (c - prevTok.current.c) / dt), prompt: Math.max(0, (p - prevTok.current.p) / dt),
}) completion: Math.max(0, (c - prevTok.current.c) / dt),
})
}
} }
prevTok.current = { p, c, t: now } prevTok.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+21 -4
View File
@@ -68,7 +68,7 @@ export const qk = {
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen // Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt). // Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
const TAKT = { const TAKT = {
graph: 3_000, // Live-Graphen im Cockpit (System, Token-Durchsatz) graph: 3_000, // System + Token: seit P4 nur noch Rueckfall/Randdaten (siehe unten)
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace, Auftragsbuch schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace, Auftragsbuch
normal: 10_000, // Dienste, Routing, Agent-Status, Health normal: 10_000, // Dienste, Routing, Agent-Status, Health
gemuetlich: 30_000, // Chronik, Ideen gemuetlich: 30_000, // Chronik, Ideen
@@ -81,6 +81,19 @@ const TAKT = {
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling. // reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms) const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
// UMBAU v3 P4: Die MESSWERTE (CPU, RAM, GPU, Temperatur, Token-Zähler, Betriebszeit)
// kommen jetzt gepusht — Ereignis `metrik`, jede Sekunde. Der Poller dieser beiden
// Endpunkte war die halbe Grundlast der Startseite (Befund B-12).
//
// Er wird trotzdem NICHT abgeschaltet, sondern gedrosselt. Grund: Beide Antworten tragen
// mehr als die Messwerte — `/api/system/status` liefert die Versions-Hashes (Sidebar-Fuß),
// `/api/system/token-stats` die Gesamtsumme und die Cloud-Ersparnis, für die das Backend
// die Tarife auflöst (Preis-Logik ist dort die einzige Wahrheit, sie hier nachzubauen wäre
// eine zweite). Beides ändert sich langsam — einmal pro Minute reicht dafür völlig.
//
// 3 s → 60 s ist eine Zwanzigstel-Last; abschalten hätte diese Felder einfrieren lassen.
const langsamWennStrom = (ms: number) => () => (sseVerbunden() ? 60_000 : ms)
// Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende // Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden. // Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden.
export const useAuftragsbuch = (refetchInterval: number = TAKT.schnell) => export const useAuftragsbuch = (refetchInterval: number = TAKT.schnell) =>
@@ -158,7 +171,8 @@ export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal }) useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
export const useSystemStatus = (refetchInterval: number = TAKT.graph) => export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval }) useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"),
refetchInterval: langsamWennStrom(refetchInterval) })
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv, // Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store. // wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
@@ -193,13 +207,16 @@ export const useJobs = (refetchInterval = 3_000, enabled = true) =>
useQuery({ useQuery({
queryKey: qk.jobs, queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"), queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
refetchInterval, // Seit P4 meldet der Fingerabdruck-Sammler Zustand UND Fortschritt der Jobs —
// der Poller ist damit auch hier nur noch Sicherheitsnetz.
refetchInterval: relax(refetchInterval),
enabled, enabled,
select: (d) => d.jobs ?? [], select: (d) => d.jobs ?? [],
}) })
export const useTokenStats = (refetchInterval: number = TAKT.graph) => export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval }) useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"),
refetchInterval: langsamWennStrom(refetchInterval) })
export const useAgentStatus = (refetchInterval: number = TAKT.normal) => export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval }) useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
+34 -4
View File
@@ -1,12 +1,42 @@
import { useMemo } from "react"
import { useSystemStatus } from "@/lib/queries" import { useSystemStatus } from "@/lib/queries"
import { useSysHistory, type SysSample } from "@/lib/metricsStore" import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
import type { SystemStatus } from "@/lib/api"
export type { SysSample } export type { SysSample }
/** Aktuelle System-Werte (für Legende) + rollende Live-Historie aus dem modul-globalen /** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
* Store (überlebt Tab-Wechsel; gefüllt vom App-Feeder). */ *
* v3-Umbau P4: Die Query läuft nur noch minütlich, solange der Ereignisstrom steht —
* ihre MESSWERTE wären damit bis zu 60 s alt. Deshalb werden sie hier mit dem letzten
* Messpunkt aus dem Strom überschrieben. Alles, was der Strom NICHT trägt (VRAM-Detail,
* Kernzahl, Versionen), bleibt aus der Query: Der Strom soll schlank bleiben, und diese
* Felder ändern sich ohnehin selten.
*/
export function useSystemHistory() { export function useSystemHistory() {
const { data: sys, error } = useSystemStatus() const { data: query, error } = useSystemStatus()
const live = useLetzteMetrik()
const hist = useSysHistory() const hist = useSysHistory()
const sys = useMemo<SystemStatus | undefined>(() => {
if (!query) return query
if (!live) return query
return {
...query,
cpu: { ...query.cpu, percent: live.cpu ?? query.cpu.percent },
ram: {
...query.ram,
percent: live.ram ?? query.ram.percent,
used: live.ram_used ?? query.ram.used,
total: live.ram_total ?? query.ram.total,
},
// gtt_used/gtt_total bleiben aus der Query — der Strom trägt nur die Auslastung.
gpu: query.gpu ? { ...query.gpu, busy_percent: live.gpu ?? query.gpu.busy_percent } : query.gpu,
disk: query.disk ? { ...query.disk, percent: live.disk ?? query.disk.percent } : query.disk,
temp: { cpu: live.temp_cpu ?? query.temp?.cpu, gpu: live.temp_gpu ?? query.temp?.gpu },
uptime_s: live.uptime_s ?? query.uptime_s,
}
}, [query, live])
return { sys, hist, error } return { sys, hist, error }
} }