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
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.
Zwei Endpunkte, ein Sammler:
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
Sekunden), Modell-Quelle aus der Karte übernommen.
GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
nur `/api/events` kennt. Danach entfernen.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
selbst und bis dahin pollt die UI wie bisher).
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
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
@@ -35,7 +40,8 @@ log = logging.getLogger(__name__)
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
@@ -66,6 +72,13 @@ def _fingerprints() -> dict[str, object]:
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception:
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
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
@@ -73,28 +86,63 @@ def _fingerprints() -> dict[str, object]:
return fp
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
async def strom():
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints()
yield ": verbunden\n\n"
seit_ping = 0.0
while True:
if await request.is_disconnected():
return
await asyncio.sleep(TICK_S)
seit_ping += TICK_S
async def _strom(request: Request, mit_metrik: bool):
"""Gemeinsamer Kern beider Endpunkte.
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
"""
alt = _fingerprints()
yield ": verbunden\n\n"
seit_abdruck = 0.0
seit_ping = 0.0
takt = METRIK_S if mit_metrik else ABDRUCK_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()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu)
if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
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",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
if seit_ping >= KEEPALIVE_S:
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:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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
*
* 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="icon" type="image/svg+xml" href="/favicon.svg" />
<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">
</head>
<body>
+16 -9
View File
@@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router"
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 { gb } from "@/lib/format"
import { cn } from "@/lib/utils"
@@ -80,10 +80,17 @@ export function Statusleiste() {
// (Merksatz aus dem Projekt: Rollen-Aliase statt Modellnamen.)
const aktivesModell = health?.brain?.role ?? routing?.lanes?.[0]?.name ?? null
const ramProzent = sys?.ram?.percent ?? 0
const tempMax = Math.max(sys?.temp?.cpu ?? 0, sys?.temp?.gpu ?? 0)
// Live-Werte kommen aus dem Strom (jede Sekunde); die Query ist nur der Stand vom
// 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 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 }> = {
live: { punkt: "bg-emerald-500", text: "Live", titel: "Ereignisstrom steht — Änderungen erscheinen sofort." },
@@ -117,7 +124,7 @@ export function Statusleiste() {
</Feld>
{/* 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>
<div className="h-2 w-24 overflow-hidden rounded-sm border border-border/50 bg-background/60" aria-hidden="true">
<div
@@ -127,19 +134,19 @@ export function Statusleiste() {
/>
</div>
<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>
</Feld>
{/* Auslastung */}
<Feld titel="Auslastung von Prozessor und Grafikeinheit">
<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%)" />
{sys?.gpu?.busy_percent != null && (
{gpuProzent != null && (
<>
<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>
+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-
// Caches an. Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz
// (queries.ts entspannt sie ×5 über sseVerbunden()). Reißt der Strom (MC2-Deploy,
// Netz), reconnectet EventSource von selbst — bis dahin pollt die UI wie früher.
// EIN EventSource auf /api/stream. Zwei Ereignisarten:
// · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
// · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
//
// v3-Umbau P3 (28.08.2026): Der Verbindungszustand wandert zusätzlich in den Store,
// damit die Statusleiste ihn ANZEIGEN kann. Vorher war er eine Modul-Variable, die nur
// queries.ts las — die Oberfläche alterte bei gerissenem Strom still weiter (Befund
// B-15). Jetzt steht dort „Live", „Nachlauf" oder „Getrennt", und man weiß, worauf man
// gerade schaut.
// Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
// sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
// pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
//
// Ebenfalls P3 (Befund B-18): Beim Wechsel des Zustands wird EINMAL invalidiert. Vorher
// las `relax()` den Zustand erst beim nächsten Refetch — nach einem Riss blieb die UI
// bis zu einem vollen entspannten Takt (bis 150 s) im langsamen Modus.
// v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
// `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
// 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 { useQueryClient } from "@tanstack/react-query"
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
import { pushMetrik } from "@/lib/metricsStore"
let verbunden = false
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() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/events")
const es = new EventSource("/api/stream")
// 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
// Übergang: einmal beim Reißen, einmal beim Heilen.
@@ -60,6 +80,12 @@ export function useEventStream() {
} 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 () => {
verbunden = false
stromLageSetzen("getrennt")
+74 -13
View File
@@ -1,13 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
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
// eine frische Modul-Instanz, sonst tragen sich die Faelle gegenseitig Daten ein.
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
async function frischerStore() {
vi.resetModules()
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", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
@@ -27,19 +35,20 @@ describe("metricsStore", () => {
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", () => {
// Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
// pro Tag in einem Dauerläufer-Tab.
it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
const { result } = renderHook(() => store.useSysHistory())
const n = store.MAX_POINTS + 20
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[0].t).toBe(20)
expect(result.current[39].t).toBe(59)
expect(result.current).toHaveLength(store.MAX_POINTS)
expect(result.current[0].t).toBe(n - store.MAX_POINTS)
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: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
@@ -48,12 +57,64 @@ describe("metricsStore", () => {
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)", () => {
// useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
// Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Änderung 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)
})
})
// ── 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 { 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 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.
//
// 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 tokHist: RateSample[] = []
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 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 useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
/** Immer-laufender Sammler (in App eingehängt, also stets gemountet): füttert den
* Store bei jedem Poll unabhängig davon, welcher Tab gerade sichtbar ist. */
// Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
// 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() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
@@ -32,8 +82,10 @@ export function useMetricsFeeder() {
useEffect(() => {
if (!sys) return
const jetzt = Date.now()
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
pushSys({
t: Date.now(),
t: jetzt,
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
@@ -48,11 +100,13 @@ export function useMetricsFeeder() {
const p = ts.prompt_tokens, c = ts.completion_tokens
if (prevTok.current) {
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
pushTok({
t: now,
prompt: Math.max(0, (p - prevTok.current.p) / dt),
completion: Math.max(0, (c - prevTok.current.c) / dt),
})
if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
pushTok({
t: now,
prompt: Math.max(0, (p - prevTok.current.p) / dt),
completion: Math.max(0, (c - prevTok.current.c) / dt),
})
}
}
prevTok.current = { p, c, t: now }
// 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
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
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
normal: 10_000, // Dienste, Routing, Agent-Status, Health
gemuetlich: 30_000, // Chronik, Ideen
@@ -81,6 +81,19 @@ const TAKT = {
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
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
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden.
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 })
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,
// 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({
queryKey: qk.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,
select: (d) => d.jobs ?? [],
})
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) =>
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 { useSysHistory, type SysSample } from "@/lib/metricsStore"
import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
import type { SystemStatus } from "@/lib/api"
export type { SysSample }
/** Aktuelle System-Werte (für Legende) + rollende Live-Historie aus dem modul-globalen
* Store (überlebt Tab-Wechsel; gefüllt vom App-Feeder). */
/** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
*
* 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() {
const { data: sys, error } = useSystemStatus()
const { data: query, error } = useSystemStatus()
const live = useLetzteMetrik()
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 }
}