feat(frontend): v3-Umbau P3 — ein Store, eine Statusleiste, endlich aria-live
Vierte Etappe. Der Client-Zustand hatte bisher keinen Ort: zwei handgebaute
useSyncExternalStore-Speicher, drei CustomEvent-Kanaele und ein localStorage-Griff
mitten in einem useState — verteilt ueber fuenf Dateien.
## Ein Store (Befund B-08/B-09)
app/store.ts (Zustand, 1,2 kB gzip) haelt genau vier Dinge: Bedienvorlieben
(Schiene, Expertenmodus, Palette), die Lage des Ereignisstroms und die
Meldungs-Warteschlange. Der Kopfkommentar nennt die Regel, nach der etwas dort
landen darf — und ein Test prueft sie: gespeichert werden AUSSCHLIESSLICH
Schienen- und Expertenmodus-Zustand, kein Strom, keine Meldungen, kein Geheimnis.
Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts): Er nimmt ab P4
jede Sekunde einen Messpunkt entgegen, ein Store-Update pro Sekunde wuerde jede
abonnierende Komponente neu rendern.
Die drei CustomEvent-Kanaele sind ERSATZLOS weg — `grep -r dispatchEvent src`
liefert nichts mehr. Wer die Schublade oeffnen will, setzt `?system=`; wer springen
will, navigiert. Auch der letzte Rest von P2 (`mc-palette-open`) ist aufgeloest.
## Statusleiste (Spezifikation §4.1)
Der Zustand der Box stand bisher verstreut: die Ampel unten in der Sidebar, die
Auslastung nur im Cockpit, das aktive Modell nur im Modell-Manager. Wer in der
Konsole arbeitete, sah gar nichts.
Jetzt eine feste Leiste ueber allen Ansichten: Motor/Hirn-Ampel (klickbar zum
Agenten) · aktive Rolle + Durchsatz mit Sparkline · geteilter Speicher ALS BALKEN
(nicht als Prozent — 128 GB Unified Memory ist die Kernzahl dieser Box) · CPU/GPU
· Temperatur (ab 85 °C bernstein) · Betriebszeit · Token-Summe · Live-Anzeige.
Die Sparklines sind eigenes SVG, ~20 Zeilen. Fuer eine 56-Pixel-Linie waere eine
Diagramm-Bibliothek Verschwendung.
Dafuer neu im Backend: `uptime_s` in /api/system/status (psutil.boot_time).
Gehoert dorthin, weil die Box sich woechentlich selbst neu startet — dann ist
"laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
Liefert psutil nichts, steht dort None und die Leiste blendet das Feld aus,
statt eine Zahl zu erfinden.
## aria-live (Befund B-11) — und diesmal mit Absender
Im ganzen Frontend gab es KEIN einziges aria-live. app/shell/Meldungen.tsx ist
jetzt die eine Stelle fuer beilaeufige Rueckmeldungen; Fehler bekommen
`assertive` und bleiben stehen, alles andere `polite` und raeumt sich weg.
Wichtig: Die Region ist keine Attrappe. Gespeist wird sie von den Uebergaengen des
Ereignisstroms und vom Konsolen-Neustart (mit dem echten Grund aus ApiError.detail).
Der erste Verbindungsaufbau wird bewusst NICHT gemeldet — sonst begruesst jede
Seite den Nutzer mit "Verbindung wieder da".
## Ehrliche Anzeige statt stillem Altern (Befund B-15/B-18)
lib/events.ts fuehrt die Stromlage jetzt im Store: live · nachlauf · getrennt.
Beim Wechsel wird EINMAL invalidiert — vorher las relax() den Zustand erst beim
naechsten Refetch, nach einem Riss blieb die UI bis zu 150 s im langsamen Modus.
## Bahnbreiten-Deckel (Befund B-10)
Die Arbeitsflaeche endet bei 1600 px. Ohne Deckel zog sich das Cockpit auf einem
3440-px-Ultrawide auf ueber 3 000 px, waehrend die Wurzel-Schriftgroesse mitwuchs
und die Zeilen GROESSER statt lesbarer machte.
## Nebenbei
Der Buendel-Budget-Check aus P0 war fragil: `ls dist/assets/index-*.js | head -1`
kann den falschen treffen, weil Rollup auch kleine geteilte Module "index-*.js"
nennt (gemessen: ein 67-Byte-Chunk neben dem 374-kB-Einstieg). Er waere dann still
immer gruen gewesen. Beide Ampel-Dateien lesen den Einstieg jetzt aus index.html.
## Verifiziert im Browser
Statusleiste: alle Felder mit Live-Daten, "Laeuft seit 1 Std 10 min"
Backend abgewuergt -> Leiste springt auf "Nachlauf" UND die aria-live-Region
meldet "Verbindung zur Zentrale verloren" (polite)
Backend zurueck -> "Live", ohne Begruessungs-Meldung
Cockpit-Kachel -> /cockpit?system=logs, Schublade offen, Reiter "System-Logs"
Arbeitsflaeche -> max-width 1600 px
33/33 Tests gruen (7 neue fuer den Store) · ESLint 0 Fehler · tsc sauber ·
Einstieg 118 153 B gzip / Budget 125 000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
464b0d20b8
commit
5aa5a484a7
@@ -67,7 +67,12 @@ jobs:
|
||||
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
|
||||
budget_gz=125000 # Stand nach P0: 110 571 B gzip
|
||||
budget_dist=3145728 # Stand nach P0: 1 477 852 B
|
||||
haupt=$(ls frontend/dist/assets/index-*.js 2>/dev/null | head -1)
|
||||
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
|
||||
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
|
||||
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
|
||||
# Hash den falschen erwischt — und das Budget waere still immer gruen.
|
||||
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
|
||||
haupt="frontend/dist/$einstieg"
|
||||
if [ -n "$haupt" ]; then
|
||||
gz=$(gzip -c "$haupt" | wc -c)
|
||||
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
|
||||
|
||||
@@ -10,6 +10,7 @@ import glob
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
@@ -197,5 +198,18 @@ def system_status() -> dict:
|
||||
"gpu": _gpu_sysfs(),
|
||||
"temp": _temps(),
|
||||
"disk": disk,
|
||||
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
|
||||
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
|
||||
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
|
||||
"uptime_s": _uptime_s(),
|
||||
"versions": check_versions_cached(),
|
||||
}
|
||||
|
||||
|
||||
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."""
|
||||
try:
|
||||
return int(time.time() - psutil.boot_time())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
+5
-1
@@ -72,7 +72,11 @@ jobs:
|
||||
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
|
||||
# reicht). Gemessen wird der frische Build, nicht das committete dist.
|
||||
if [ -d "$d/dist/assets" ]; then
|
||||
haupt=$(ls "$d"/dist/assets/index-*.js 2>/dev/null | head -1)
|
||||
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
|
||||
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
|
||||
# falschen — das Budget waere still immer gruen.
|
||||
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
|
||||
haupt="$d/$einstieg"
|
||||
if [ -n "$haupt" ]; then
|
||||
gz=$(gzip -c "$haupt" | wc -c)
|
||||
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
|
||||
|
||||
-6
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-14
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as u,j as e,r as M,t as C,O as z,P as k,Q as y,K as v,U as L,V as G,W as D,b as f,Y as T}from"./index-Bb8GyPHo.js";import{a as w,L as B,C as P,Z as E}from"./zap-CsukfbMs.js";import{L as R}from"./layers-y2iqT_Gl.js";import{S as I}from"./shield-DNyHWe3b.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-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";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+9
-9
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{u,ab as m,k as x,j as s,y as n,ac as b,b as p,n as f,q as h}from"./index-Bb8GyPHo.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-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};
|
||||
@@ -1 +0,0 @@
|
||||
import{I as b,u as f,J as h,r as d,j as e,b as c,T as g,S as p,n as j,N,q as x}from"./index-Bb8GyPHo.js";import{E as v}from"./external-link-P1_1JELD.js";import{R as w}from"./refresh-cw-C3saW9zg.js";function T(){const{data:s}=b(),m=f(),t=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const r=await j("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(r.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${r.err||"Unbekannter Fehler"}`),N(m,x.agentStatus,x.services)}catch(r){n(`Fehlgeschlagen: ${(r==null?void 0:r.message)||r}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),t&&e.jsxs("a",{href:t,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(v,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),t?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(g,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(w,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:t,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(p,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{T as KonsoleView};
|
||||
@@ -0,0 +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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-Bb8GyPHo.js";import{J as et}from"./JobsBar-D2vfhT2b.js";import{C as tt}from"./code-xml-D7ACEH7O.js";import{C as ue,Z as ie,L as rt,a as st}from"./zap-CsukfbMs.js";import{S as ge}from"./search-CKPXZNvI.js";import{P as nt}from"./power-BF5yiJ4i.js";import{C as $e}from"./chevron-down-BGRm8kVZ.js";import{L as at}from"./layers-y2iqT_Gl.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-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";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as s,j as e}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as s,j as e}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
var E=r=>{throw TypeError(r)};var C=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(C(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)=>(C(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(C(r,e,"access private method"),t);import{a3 as B,a4 as F,a5 as K,a6 as I,a7 as P,u as A,r as f,a8 as J,a9 as Q,c as T,aa as U,j as s,T as D,b as M,L,K as H,q as V,n as R}from"./index-Bb8GyPHo.js";import{J as _}from"./JobsBar-D2vfhT2b.js";import{C as z}from"./circle-check-Crmfz00W.js";var c,g,o,h,m,w,O,q,G=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&K(t.mutationKey)!==K(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,O).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,O).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},O=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new 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(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function te(){const r=A(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:V.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(_,{}),d&&s.jsxs("div",{className:M("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(z,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(H,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:M("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft …":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{te as SkillsView};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
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}}/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=J(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:_.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(z,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(V,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft …":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -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,ai as ro,aj as io,j as $,ak as oo,al as ao,n as so,b as Ae,L as Ke,Y as uo,am as lo}from"./index-Bb8GyPHo.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-C3saW9zg.js";import{S as po}from"./search-CKPXZNvI.js";import{F as go}from"./file-text-Bdam-Njc.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-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";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as r}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as r}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as o}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as o}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1,6 +0,0 @@
|
||||
import{c}from"./index-Bb8GyPHo.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const r=c("CircleCheck",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);export{r as C};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c}from"./index-Bb8GyPHo.js";/**
|
||||
import{c}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as e}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as a}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as e}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
-245
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
import{ax as r}from"./index-CB2Jz083.js";var o=r();export{o as r};
|
||||
+260
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
||||
import{at as r}from"./index-Bb8GyPHo.js";var o=r();export{o as r};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as a}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as o}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as o}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as e}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c}from"./index-Bb8GyPHo.js";/**
|
||||
import{c}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as a}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-Bb8GyPHo.js";/**
|
||||
import{c as a}from"./index-CB2Jz083.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<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-Bb8GyPHo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DErfN6d3.css">
|
||||
<script type="module" crossorigin src="/assets/index-CB2Jz083.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQUMn05v.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+31
-1
@@ -21,7 +21,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"recharts": "^3.9.0",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -6455,6 +6456,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.15",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
||||
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"recharts": "^3.9.0",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
+54
-55
@@ -5,9 +5,12 @@ import { NAV, navFuerPfad } from "@/nav"
|
||||
import { ExpertToggle } from "@/components/ExpertToggle"
|
||||
|
||||
import { useHealth, useSystemStatus } from "@/lib/queries"
|
||||
import type { Health, SystemStatus } from "@/lib/api"
|
||||
import type { Health } from "@/lib/api"
|
||||
import { useMetricsFeeder } from "@/lib/metricsStore"
|
||||
import { useEventStream } from "@/lib/events"
|
||||
import { useMC, usePaletteOffen, useSchieneEingeklappt } from "@/app/store"
|
||||
import { Statusleiste } from "@/app/shell/Statusleiste"
|
||||
import { Meldungen } from "@/app/shell/Meldungen"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2):
|
||||
@@ -52,60 +55,49 @@ export function Shell() {
|
||||
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
|
||||
})
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
|
||||
// v3-Umbau P3: Schienen-Zustand und Palette liegen jetzt im Store (Befund B-08/B-09).
|
||||
// Vorher: ein localStorage-Zugriff mitten in useState plus ein GEFAELSCHTES
|
||||
// KeyboardEvent, das der Kopfzeilen-Knopf aufs Dokument feuerte.
|
||||
const schieneEingeklappt = useSchieneEingeklappt()
|
||||
const schieneUmschalten = useMC((st) => st.schieneUmschalten)
|
||||
const paletteOffen = usePaletteOffen()
|
||||
const palette = useMC((st) => st.palette)
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
// Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren
|
||||
// eigenen Offen-Zustand und soll nicht bei jedem Strg+K neu nachgeladen werden.
|
||||
// Inhalt und soll nicht bei jedem Strg+K neu nachgeladen werden.
|
||||
const [paletteBereit, setPaletteBereit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const wecken = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") setPaletteBereit(true)
|
||||
if (paletteOffen) setPaletteBereit(true)
|
||||
}, [paletteOffen])
|
||||
|
||||
useEffect(() => {
|
||||
const taste = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault()
|
||||
palette(!useMC.getState().ui.paletteOffen)
|
||||
}
|
||||
}
|
||||
const rufen = () => setPaletteBereit(true)
|
||||
document.addEventListener("keydown", wecken)
|
||||
window.addEventListener("mc-palette-open", rufen)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", wecken)
|
||||
window.removeEventListener("mc-palette-open", rufen)
|
||||
}
|
||||
}, [])
|
||||
document.addEventListener("keydown", taste)
|
||||
return () => document.removeEventListener("keydown", taste)
|
||||
}, [palette])
|
||||
|
||||
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
|
||||
navigate({ to: ".", search: (alt: Record<string, unknown>) => ({ ...alt, system: tab }), replace: true })
|
||||
navigate({ to: ".", search: (alt) => ({ ...alt, system: tab }), replace: true })
|
||||
}, [navigate])
|
||||
|
||||
// isError mitnehmen: React Query behält bei Fehlern die letzten Daten — ohne den
|
||||
// Fehler-Blick zeigte die Sidebar bei totem Backend ewig den letzten grünen Stand.
|
||||
const { data: health, isError: backendDown } = useHealth()
|
||||
const { data: sysStatus } = useSystemStatus()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("dark")
|
||||
}, [])
|
||||
|
||||
// Zwei Alt-Kanäle aus der Zeit vor dem Router. Sie bleiben in P2 bestehen, damit die
|
||||
// Aufrufer (Cockpit-Kacheln, Agent-Ansicht, Auftragsbuch) unverändert weiterlaufen —
|
||||
// gefüttert wird jetzt aber der Router, nicht mehr ein lokales useState.
|
||||
// In P3 lösen Store und Palette diese Kanäle ganz ab (Befund B-08).
|
||||
useEffect(() => {
|
||||
const handleOpen = (e: Event) => {
|
||||
const tab = (e as CustomEvent).detail?.tab
|
||||
schubladeSetzen(tab === "logs" ? "logs" : "wartung")
|
||||
}
|
||||
window.addEventListener("open-system-drawer", handleOpen)
|
||||
return () => window.removeEventListener("open-system-drawer", handleOpen)
|
||||
}, [schubladeSetzen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleNav = (e: Event) => {
|
||||
const v = (e as CustomEvent).detail?.view as string | undefined
|
||||
const ziel = NAV.find((n) => n.id === v)
|
||||
if (ziel) navigate({ to: ziel.pfad })
|
||||
}
|
||||
window.addEventListener("mc-navigate", handleNav)
|
||||
return () => window.removeEventListener("mc-navigate", handleNav)
|
||||
}, [navigate])
|
||||
// Hier hingen bis P2 zwei CustomEvent-Zuhörer (`open-system-drawer`, `mc-navigate`),
|
||||
// über die andere Ansichten die Shell fernsteuerten. In P3 sind sie ersatzlos weg:
|
||||
// Wer die Schublade öffnen will, setzt `?system=`; wer springen will, navigiert. Der
|
||||
// Zustand liegt damit da, wo man ihn sehen und teilen kann (Befund B-08).
|
||||
|
||||
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
|
||||
useEffect(() => { setMobileNavOpen(false) }, [pfad])
|
||||
@@ -128,6 +120,9 @@ export function Shell() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Beilaeufige Rueckmeldungen — EINE Stelle, und endlich mit aria-live (B-11). */}
|
||||
<Meldungen />
|
||||
|
||||
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
|
||||
<Suspense fallback={null}>
|
||||
{paletteBereit && <CommandPalette />}
|
||||
@@ -143,21 +138,14 @@ export function Shell() {
|
||||
{/* Sidebar (Desktop, ab md) */}
|
||||
<aside className={cn(
|
||||
"hidden md:flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
|
||||
sidebarCollapsed ? "w-16" : "w-60"
|
||||
schieneEingeklappt ? "w-16" : "w-60"
|
||||
)}>
|
||||
<SidebarContent
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => {
|
||||
setSidebarCollapsed((c) => {
|
||||
const next = !c
|
||||
localStorage.setItem("mc_sidebar_collapsed", next.toString())
|
||||
return next
|
||||
})
|
||||
}}
|
||||
collapsed={schieneEingeklappt}
|
||||
onToggleCollapse={schieneUmschalten}
|
||||
pfad={pfad}
|
||||
health={health}
|
||||
backendDown={backendDown}
|
||||
sysStatus={sysStatus}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -180,7 +168,6 @@ export function Shell() {
|
||||
pfad={pfad}
|
||||
health={health}
|
||||
backendDown={backendDown}
|
||||
sysStatus={sysStatus}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -211,7 +198,7 @@ export function Shell() {
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("mc-palette-open"))}
|
||||
onClick={() => palette(true)}
|
||||
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
|
||||
>
|
||||
<CommandIcon className="h-3.5 w-3.5" />
|
||||
@@ -221,11 +208,20 @@ export function Shell() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
|
||||
<Suspense fallback={<ViewLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
{/* Bahnbreiten-Deckel (Befund B-10): Ohne ihn zieht sich das Cockpit auf einem
|
||||
3440-px-Ultrawide auf ueber 3 000 px Breite; die Wurzel-Schriftgroesse waechst
|
||||
dabei mit und macht die Zeilen GROESSER statt lesbarer. 1600 px sind die Grenze,
|
||||
ab der eine Zeile nicht mehr am Stueck ueberblickt wird — der Rest bleibt Rand. */}
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="mx-auto w-full max-w-[1600px] p-4 md:p-6">
|
||||
<Suspense fallback={<ViewLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Zeigt den Zustand der Box IMMER, ohne den Arbeitsbereich zu blockieren (§4.1). */}
|
||||
<Statusleiste />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -234,7 +230,7 @@ export function Shell() {
|
||||
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
|
||||
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
|
||||
function SidebarContent({
|
||||
collapsed, onToggleCollapse, onClose, pfad, health, backendDown, sysStatus,
|
||||
collapsed, onToggleCollapse, onClose, pfad, health, backendDown,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggleCollapse?: () => void
|
||||
@@ -242,9 +238,12 @@ function SidebarContent({
|
||||
pfad: string
|
||||
health: Health | undefined
|
||||
backendDown: boolean
|
||||
sysStatus: SystemStatus | undefined
|
||||
}) {
|
||||
const aktiv = navFuerPfad(pfad)
|
||||
// Kein Prop-Drilling mehr: derselbe Query-Key, also dieselbe Anfrage — TanStack Query
|
||||
// dedupliziert. Die Versionen stehen bewusst hier unten und nicht in der Statusleiste:
|
||||
// Man schaut sie selten an, und die Leiste soll knapp bleiben.
|
||||
const { data: sysStatus } = useSystemStatus()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect } from "react"
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"
|
||||
import { useMC, useMeldungen, type MeldungsArt } from "@/app/store"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Meldungs-Region (v3-Umbau P3, Befund B-11).
|
||||
//
|
||||
// Vorher gab es im ganzen Frontend KEIN einziges `aria-live`. Dienst neu gestartet,
|
||||
// Idee angenommen, Modell geladen, Backend weg — für Screenreader war all das stumm,
|
||||
// und für sehende Nutzer stand es je nach Ansicht irgendwo anders (oder in einem Dialog,
|
||||
// den man wegklicken musste).
|
||||
//
|
||||
// Jetzt eine Stelle für alle beiläufigen Rückmeldungen. `role="status"` mit
|
||||
// `aria-live="polite"` heißt: vorlesen, aber den Nutzer nicht unterbrechen. Fehler
|
||||
// bekommen `assertive` — die dürfen unterbrechen.
|
||||
//
|
||||
// Dialoge bleiben, was sie sind: Sie fragen etwas und warten auf eine Antwort. Hier
|
||||
// landet nur, was man zur Kenntnis nimmt.
|
||||
|
||||
const AUSBLENDEN_NACH_MS = 6_000
|
||||
|
||||
const STIL: Record<MeldungsArt, { rand: string; farbe: string; Icon: typeof Info }> = {
|
||||
info: { rand: "border-sky-500/40", farbe: "text-sky-300", Icon: Info },
|
||||
erfolg: { rand: "border-emerald-500/40", farbe: "text-emerald-300", Icon: CheckCircle2 },
|
||||
warnung: { rand: "border-amber-500/40", farbe: "text-amber-300", Icon: AlertTriangle },
|
||||
fehler: { rand: "border-red-500/40", farbe: "text-red-300", Icon: XCircle },
|
||||
}
|
||||
|
||||
export function Meldungen() {
|
||||
const meldungen = useMeldungen()
|
||||
const meldungWeg = useMC((s) => s.meldungWeg)
|
||||
|
||||
// Alte Meldungen räumen sich selbst weg. Fehler bleiben stehen — wer einen Fehler
|
||||
// verpasst, weil er gerade woanders hinsah, hat nichts davon.
|
||||
useEffect(() => {
|
||||
const uhren = meldungen
|
||||
.filter((m) => m.art !== "fehler")
|
||||
.map((m) =>
|
||||
window.setTimeout(
|
||||
() => meldungWeg(m.id),
|
||||
Math.max(1_000, AUSBLENDEN_NACH_MS - (Date.now() - m.seit)),
|
||||
),
|
||||
)
|
||||
return () => uhren.forEach(window.clearTimeout)
|
||||
}, [meldungen, meldungWeg])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-full max-w-sm flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{meldungen.map((m) => {
|
||||
const { rand, farbe, Icon } = STIL[m.art]
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-start gap-2.5 rounded-lg border bg-card/95 px-3.5 py-2.5 shadow-xl backdrop-blur",
|
||||
rand,
|
||||
)}
|
||||
// Fehler duerfen unterbrechen, alles andere wartet auf eine Pause.
|
||||
aria-live={m.art === "fehler" ? "assertive" : "polite"}
|
||||
>
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", farbe)} aria-hidden="true" />
|
||||
<p className="flex-1 text-xs leading-relaxed text-foreground">{m.text}</p>
|
||||
<button
|
||||
onClick={() => meldungWeg(m.id)}
|
||||
aria-label="Meldung schließen"
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useHealth, useRouting, useSystemStatus, useTokenStats } from "@/lib/queries"
|
||||
import { useSysHistory, useTokHistory } from "@/lib/metricsStore"
|
||||
import { useStromLage } from "@/app/store"
|
||||
import { gb } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Die Statusleiste (v3-Umbau P3, Spezifikation §4.1).
|
||||
//
|
||||
// Sie zeigt den Zustand der Box IMMER und blockiert dabei nie den Arbeitsbereich.
|
||||
// Vorher stand dasselbe verstreut: die Ampel unten in der Sidebar, Auslastung nur im
|
||||
// Cockpit, das aktive Modell nur im Modell-Manager. Wer in der Konsole arbeitete, sah
|
||||
// gar nichts — und merkte einen Engine-Ausfall erst, wenn etwas nicht ging.
|
||||
//
|
||||
// Die wichtigste Zahl hier ist nicht CPU oder RAM: Es ist der geteilte Speicher. Die Box
|
||||
// hat 128 GB Unified Memory für alles — Modelle, System, Dienste. Deshalb steht er als
|
||||
// BALKEN da und nicht als Prozentzahl: Man muss auf einen Blick sehen, wie viel Platz ein
|
||||
// weiteres Modell noch hätte.
|
||||
|
||||
/** Sekunden → „12 T 4 Std". Bewusst grob: auf die Minute genau interessiert hier niemand. */
|
||||
function betriebszeit(s: number | null | undefined): string | null {
|
||||
if (s == null || s < 0) return null
|
||||
const tage = Math.floor(s / 86_400)
|
||||
const std = Math.floor((s % 86_400) / 3_600)
|
||||
const min = Math.floor((s % 3_600) / 60)
|
||||
if (tage > 0) return `${tage} T ${std} Std`
|
||||
if (std > 0) return `${std} Std ${min} min`
|
||||
return `${min} min`
|
||||
}
|
||||
|
||||
/** Winziger Sparkline-Pfad aus Messwerten. Eigenes SVG statt einer Diagramm-Bibliothek:
|
||||
* Für eine 60-Pixel-Linie in einer Statusleiste wäre alles andere Verschwendung. */
|
||||
function Sparkline({ werte, farbe, breite = 56, hoehe = 14 }: {
|
||||
werte: number[]
|
||||
farbe: string
|
||||
breite?: number
|
||||
hoehe?: number
|
||||
}) {
|
||||
if (werte.length < 2) return <div style={{ width: breite, height: hoehe }} aria-hidden="true" />
|
||||
const max = Math.max(...werte, 1)
|
||||
const schritt = breite / (werte.length - 1)
|
||||
const d = werte
|
||||
.map((v, i) => `${i === 0 ? "M" : "L"}${(i * schritt).toFixed(1)},${(hoehe - (v / max) * hoehe).toFixed(1)}`)
|
||||
.join(" ")
|
||||
return (
|
||||
<svg width={breite} height={hoehe} viewBox={`0 0 ${breite} ${hoehe}`} aria-hidden="true" className="shrink-0">
|
||||
<path d={d} fill="none" stroke={farbe} strokeWidth="1.25" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Feld({ children, titel, className }: {
|
||||
children: React.ReactNode
|
||||
titel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
title={titel}
|
||||
className={cn("flex shrink-0 items-center gap-1.5 border-r border-border/30 px-3", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Statusleiste() {
|
||||
const { data: health, isError: zentraleWeg } = useHealth()
|
||||
const { data: sys } = useSystemStatus()
|
||||
const { data: routing } = useRouting()
|
||||
const { data: tokens } = useTokenStats()
|
||||
const sysVerlauf = useSysHistory()
|
||||
const tokVerlauf = useTokHistory()
|
||||
const lage = useStromLage()
|
||||
|
||||
const engineOk = !zentraleWeg && !!health?.engine_reachable
|
||||
const hirnOk = engineOk && (health?.brain ? health.brain.ready : true)
|
||||
|
||||
// Rollen-Alias statt Dateiname: „fast", nicht „Qwen3.6-35B-A3B-Q4_K_M.gguf".
|
||||
// (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)
|
||||
const durchsatz = tokVerlauf.length ? tokVerlauf[tokVerlauf.length - 1].completion : 0
|
||||
const zeit = betriebszeit(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." },
|
||||
nachlauf: { punkt: "bg-amber-500", text: "Nachlauf", titel: "Ereignisstrom gerissen — die Ansicht fragt wieder im Takt nach." },
|
||||
getrennt: { punkt: "bg-red-500", text: "Getrennt", titel: "Keine Verbindung zur Zentrale — die Zahlen sind veraltet." },
|
||||
}
|
||||
const lageStil = LAGE_STIL[lage]
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="flex h-11 shrink-0 items-stretch overflow-x-auto border-t border-border/40 bg-card/40 font-mono text-[11px] backdrop-blur-sm scrollbar-thin"
|
||||
aria-label="Zustand der Box"
|
||||
>
|
||||
{/* Ampel: Motor + Hirn. Der Klick führt dorthin, wo man es reparieren kann. */}
|
||||
<Link to="/agent" className="flex shrink-0 items-center gap-2.5 border-r border-border/30 px-3 transition-colors hover:bg-accent/50">
|
||||
<span className="flex items-center gap-1.5" title={engineOk ? "Motor (Engine) läuft" : "Motor (Engine) läuft nicht"}>
|
||||
<span className={cn("h-2 w-2 rounded-full", engineOk ? "bg-emerald-500" : "bg-red-500")} aria-hidden="true" />
|
||||
<span className={engineOk ? "text-muted-foreground" : "font-semibold text-red-400"}>Motor</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5" title={hirnOk ? "Lucys Hirn ist bereit" : "Lucys Hirn ist nicht bereit"}>
|
||||
<span className={cn("h-2 w-2 rounded-full", hirnOk ? "bg-emerald-500" : "bg-amber-500")} aria-hidden="true" />
|
||||
<span className={hirnOk ? "text-muted-foreground" : "font-semibold text-amber-400"}>Hirn</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Aktives Modell + Durchsatz */}
|
||||
<Feld titel="Aktive Rolle und aktueller Ausgabe-Durchsatz">
|
||||
<span className="font-semibold text-primary">{aktivesModell ?? "—"}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{durchsatz > 0 ? `${durchsatz.toFixed(0)} t/s` : "leerlauf"}</span>
|
||||
<Sparkline werte={tokVerlauf.map((p) => p.completion)} farbe="hsl(172 72% 50%)" />
|
||||
</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"}>
|
||||
<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={cn("h-full transition-[width] duration-500",
|
||||
ramProzent > 90 ? "bg-red-500" : ramProzent > 75 ? "bg-amber-500" : "bg-primary")}
|
||||
style={{ width: `${Math.min(100, ramProzent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-foreground">
|
||||
{sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} 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>
|
||||
<Sparkline werte={sysVerlauf.map((p) => p.cpu)} farbe="hsl(199 89% 58%)" />
|
||||
{sys?.gpu?.busy_percent != null && (
|
||||
<>
|
||||
<span className="ml-1 text-muted-foreground">GPU</span>
|
||||
<span className="tabular-nums text-foreground">{Math.round(sys.gpu.busy_percent)} %</span>
|
||||
</>
|
||||
)}
|
||||
</Feld>
|
||||
|
||||
{/* Temperatur — ab 85 °C bernsteinfarben, das ist bei dieser APU die Grenze,
|
||||
ab der die Taktrate fällt und alles langsamer wird. */}
|
||||
{tempMax > 0 && (
|
||||
<Feld titel="Höchste gemessene Temperatur (Prozessor oder Grafikeinheit)">
|
||||
<span className="text-muted-foreground">Temp</span>
|
||||
<span className={cn("tabular-nums", tempMax >= 85 ? "font-semibold text-amber-400" : "text-foreground")}>
|
||||
{Math.round(tempMax)} °C
|
||||
</span>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{zeit && (
|
||||
<Feld titel="Betriebszeit seit dem letzten Neustart der Box">
|
||||
<span className="text-muted-foreground">Läuft seit</span>
|
||||
<span className="tabular-nums text-foreground">{zeit}</span>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<Feld titel="Seit Beginn verarbeitete Token und die damit gesparten Cloud-Kosten">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{tokens.total_tokens.toLocaleString("de-DE")} Token · {tokens.saved_eur.toFixed(0)} € gespart
|
||||
</span>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{/* Ehrlichkeits-Anzeige: sagt, WORAUF man gerade schaut. Ohne sie altert die
|
||||
Oberflaeche still weiter, wenn der Strom reisst (Befund B-15). */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5 px-3" title={lageStil.titel}>
|
||||
<span className={cn("h-2 w-2 rounded-full", lageStil.punkt, lage === "live" && "animate-pulse")} aria-hidden="true" />
|
||||
<span className="text-muted-foreground">{lageStil.text}</span>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { useMC, melden, stromLageSetzen } from "./store"
|
||||
|
||||
function zuruecksetzen() {
|
||||
useMC.setState({
|
||||
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
|
||||
strom: { lage: "getrennt", letztesEreignis: null },
|
||||
meldungen: [],
|
||||
})
|
||||
}
|
||||
|
||||
describe("Store", () => {
|
||||
beforeEach(zuruecksetzen)
|
||||
|
||||
it("schaltet die Schiene um", () => {
|
||||
useMC.getState().schieneUmschalten()
|
||||
expect(useMC.getState().ui.schieneEingeklappt).toBe(true)
|
||||
useMC.getState().schieneUmschalten()
|
||||
expect(useMC.getState().ui.schieneEingeklappt).toBe(false)
|
||||
})
|
||||
|
||||
it("öffnet und schließt die Palette", () => {
|
||||
useMC.getState().palette(true)
|
||||
expect(useMC.getState().ui.paletteOffen).toBe(true)
|
||||
useMC.getState().palette(false)
|
||||
expect(useMC.getState().ui.paletteOffen).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt Meldungen entgegen und gibt jeder einen eigenen Schlüssel", () => {
|
||||
melden("erfolg", "Dienst neu gestartet")
|
||||
melden("erfolg", "Dienst neu gestartet")
|
||||
const m = useMC.getState().meldungen
|
||||
expect(m).toHaveLength(2)
|
||||
expect(m[0].id).not.toBe(m[1].id)
|
||||
})
|
||||
|
||||
// Ohne Deckel wächst die Liste bei einem Fehlersturm ins Unendliche und überdeckt
|
||||
// die halbe Oberfläche.
|
||||
it("hält höchstens sechs Meldungen und wirft die ältesten weg", () => {
|
||||
for (let i = 0; i < 10; i++) melden("info", `Meldung ${i}`)
|
||||
const m = useMC.getState().meldungen
|
||||
expect(m).toHaveLength(6)
|
||||
expect(m[0].text).toBe("Meldung 4")
|
||||
expect(m[5].text).toBe("Meldung 9")
|
||||
})
|
||||
|
||||
it("entfernt eine Meldung gezielt", () => {
|
||||
melden("warnung", "eins")
|
||||
melden("warnung", "zwei")
|
||||
const id = useMC.getState().meldungen[0].id
|
||||
useMC.getState().meldungWeg(id)
|
||||
expect(useMC.getState().meldungen.map((x) => x.text)).toEqual(["zwei"])
|
||||
})
|
||||
|
||||
it("führt die Lage des Ereignisstroms", () => {
|
||||
stromLageSetzen("live")
|
||||
expect(useMC.getState().strom.lage).toBe("live")
|
||||
stromLageSetzen("nachlauf")
|
||||
expect(useMC.getState().strom.lage).toBe("nachlauf")
|
||||
})
|
||||
})
|
||||
|
||||
// Die Regel aus dem Kopfkommentar des Stores, als Test: Hier darf NICHTS liegen, was
|
||||
// in die URL oder in TanStack Query gehört — und schon gar kein Geheimnis (Befund B-01).
|
||||
describe("Store hält nur, was er halten darf", () => {
|
||||
beforeEach(zuruecksetzen)
|
||||
|
||||
it("merkt sich ausschließlich Bedienvorlieben", () => {
|
||||
useMC.getState().schieneUmschalten()
|
||||
useMC.getState().expertenmodus(true)
|
||||
useMC.getState().palette(true)
|
||||
stromLageSetzen("live")
|
||||
melden("info", "sollte nicht überleben")
|
||||
|
||||
const gespeichert = JSON.parse(localStorage.getItem("mc_ui") ?? "{}")
|
||||
expect(gespeichert.state).toEqual({
|
||||
ui: { schieneEingeklappt: true, expertenmodus: true },
|
||||
})
|
||||
// Weder der Offen-Zustand der Palette noch Strom oder Meldungen wandern in den
|
||||
// Speicher: alles drei wäre beim nächsten Start gelogen.
|
||||
expect(JSON.stringify(gespeichert)).not.toContain("paletteOffen")
|
||||
expect(JSON.stringify(gespeichert)).not.toContain("meldungen")
|
||||
expect(JSON.stringify(gespeichert)).not.toContain("strom")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
// Der EINE Client-Speicher (v3-Umbau P3, Befund B-08/B-09).
|
||||
//
|
||||
// Vorher verteilte sich derselbe Zweck über fünf Dateien: zwei handgebaute
|
||||
// `useSyncExternalStore`-Speicher (Metrik-Verlauf, Expertenmodus) und drei
|
||||
// `CustomEvent`-Kanäle (`mc-navigate`, `open-system-drawer`, `mc-expert-mode-change`,
|
||||
// dazu in P2 `mc-palette-open`). Das funktionierte — aber ein Tippfehler im
|
||||
// Ereignisnamen fiel erst zur Laufzeit auf, und niemand konnte auf einen Blick sagen,
|
||||
// welcher Zustand es überhaupt gibt.
|
||||
//
|
||||
// DIE REGEL, nach der hier etwas landet:
|
||||
// · Steht es in der URL? → gehört in den Router (app/router.tsx), nicht hierher.
|
||||
// · Kommt es vom Server? → gehört in TanStack Query, nicht hierher.
|
||||
// · Ist es flüchtig (Hover, Entwurf im Eingabefeld)? → useState in der Komponente.
|
||||
// · Sonst: hier.
|
||||
//
|
||||
// Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts). Er nimmt ab P4
|
||||
// jede Sekunde einen Messpunkt entgegen; ein Store-Update pro Sekunde würde jede
|
||||
// abonnierende Komponente neu rendern. Ein eigener `useSyncExternalStore` außerhalb
|
||||
// von React ist dafür das richtige Werkzeug — und bleibt es.
|
||||
|
||||
export type MeldungsArt = "info" | "erfolg" | "warnung" | "fehler"
|
||||
|
||||
export interface Meldung {
|
||||
id: string
|
||||
art: MeldungsArt
|
||||
text: string
|
||||
/** Zeitpunkt in ms — die Meldungsliste blendet Alte selbst aus. */
|
||||
seit: number
|
||||
}
|
||||
|
||||
/** Zustand des Ereignisstroms. Ab P4 füttert ihn der Stream selbst; in P3 setzt ihn
|
||||
* lib/events.ts, damit die Statusleiste schon jetzt die Wahrheit zeigt. */
|
||||
export type StromLage = "live" | "nachlauf" | "getrennt"
|
||||
|
||||
interface MC {
|
||||
ui: {
|
||||
schieneEingeklappt: boolean
|
||||
paletteOffen: boolean
|
||||
expertenmodus: boolean
|
||||
}
|
||||
strom: {
|
||||
lage: StromLage
|
||||
letztesEreignis: number | null
|
||||
}
|
||||
meldungen: Meldung[]
|
||||
|
||||
schieneUmschalten: () => void
|
||||
palette: (offen: boolean) => void
|
||||
expertenmodus: (an: boolean) => void
|
||||
stromLage: (lage: StromLage) => void
|
||||
stromEreignis: () => void
|
||||
melden: (art: MeldungsArt, text: string) => void
|
||||
meldungWeg: (id: string) => void
|
||||
}
|
||||
|
||||
// Eigener Zähler statt Date.now()/Math.random(): zwei Meldungen in derselben
|
||||
// Millisekunde bekamen sonst denselben Schlüssel, und React zeigte nur eine an.
|
||||
let meldungsZaehler = 0
|
||||
|
||||
export const useMC = create<MC>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
|
||||
strom: { lage: "getrennt", letztesEreignis: null },
|
||||
meldungen: [],
|
||||
|
||||
schieneUmschalten: () =>
|
||||
set((s) => ({ ui: { ...s.ui, schieneEingeklappt: !s.ui.schieneEingeklappt } })),
|
||||
|
||||
palette: (offen) => set((s) => ({ ui: { ...s.ui, paletteOffen: offen } })),
|
||||
|
||||
expertenmodus: (an) => set((s) => ({ ui: { ...s.ui, expertenmodus: an } })),
|
||||
|
||||
stromLage: (lage) => set((s) => ({ strom: { ...s.strom, lage } })),
|
||||
|
||||
stromEreignis: () =>
|
||||
set((s) => ({ strom: { ...s.strom, letztesEreignis: Date.now() } })),
|
||||
|
||||
melden: (art, text) =>
|
||||
set((s) => ({
|
||||
meldungen: [
|
||||
...s.meldungen,
|
||||
{ id: `m${++meldungsZaehler}`, art, text, seit: Date.now() },
|
||||
].slice(-6), // mehr als sechs liest ohnehin niemand
|
||||
})),
|
||||
|
||||
meldungWeg: (id) => set((s) => ({ meldungen: s.meldungen.filter((m) => m.id !== id) })),
|
||||
}),
|
||||
{
|
||||
name: "mc_ui",
|
||||
// NUR Bedienvorlieben werden gemerkt. Kein Geheimnis (siehe P1/B-01), kein
|
||||
// Serverdatum, kein Stromzustand — der wäre beim nächsten Start ohnehin gelogen.
|
||||
partialize: (s) => ({
|
||||
ui: { schieneEingeklappt: s.ui.schieneEingeklappt, expertenmodus: s.ui.expertenmodus },
|
||||
}),
|
||||
merge: (gespeichert, aktuell) => {
|
||||
const g = gespeichert as { ui?: Partial<MC["ui"]> } | undefined
|
||||
return { ...aktuell, ui: { ...aktuell.ui, ...(g?.ui ?? {}) } }
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// ── Schmale Zugriffe ────────────────────────────────────────────────────────
|
||||
// Komponenten sollen nur das abonnieren, was sie wirklich brauchen — sonst rendert
|
||||
// die Schiene neu, weil eine Meldung kam.
|
||||
export const useExpertenmodus = () => useMC((s) => s.ui.expertenmodus)
|
||||
export const useSchieneEingeklappt = () => useMC((s) => s.ui.schieneEingeklappt)
|
||||
export const usePaletteOffen = () => useMC((s) => s.ui.paletteOffen)
|
||||
export const useStromLage = () => useMC((s) => s.strom.lage)
|
||||
export const useMeldungen = () => useMC((s) => s.meldungen)
|
||||
|
||||
/** Für Code außerhalb von React (lib/events.ts, api-Fehlerbehandlung). */
|
||||
export const melden = (art: MeldungsArt, text: string) => useMC.getState().melden(art, text)
|
||||
export const stromLageSetzen = (lage: StromLage) => useMC.getState().stromLage(lage)
|
||||
export const stromEreignis = () => useMC.getState().stromEreignis()
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Sparkles, SlidersHorizontal } from "lucide-react"
|
||||
import { useExpertMode, setExpertMode } from "@/lib/useExpertMode"
|
||||
import { useExpertenmodus, useMC } from "@/app/store"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Zwei-Zustand-Pille im Header: „Einfach" (aufgeräumt) ↔ „Experte" (alle Details).
|
||||
export function ExpertToggle() {
|
||||
const expert = useExpertMode()
|
||||
const expert = useExpertenmodus()
|
||||
const setExpertMode = useMC((s) => s.expertenmodus)
|
||||
return (
|
||||
<div
|
||||
className="flex items-center rounded-md border border-border/40 bg-background/40 p-0.5"
|
||||
|
||||
@@ -268,6 +268,9 @@ export interface SystemStatus {
|
||||
} | null
|
||||
temp: { cpu?: number; gpu?: number } | null
|
||||
disk: { total: number; used: number; percent: number } | null
|
||||
/** Sekunden seit dem Systemstart (v3-Umbau P3). null, wenn die Box es nicht liefert —
|
||||
* die Statusleiste blendet das Feld dann aus, statt eine Zahl zu erfinden. */
|
||||
uptime_s?: number | null
|
||||
versions?: Versions
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
// SSE-Eventstrom (UMBAU v3 P3a) — Gegenstück zu backend/routers/events.py.
|
||||
// SSE-Eventstrom — 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
|
||||
|
||||
let verbunden = false
|
||||
export const sseVerbunden = () => verbunden
|
||||
@@ -14,14 +26,44 @@ export function useEventStream() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/events")
|
||||
es.onopen = () => { verbunden = true }
|
||||
es.onerror = () => { verbunden = false }
|
||||
// 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.
|
||||
let warSchonMalVerbunden = false
|
||||
|
||||
es.onopen = () => {
|
||||
verbunden = true
|
||||
stromLageSetzen("live")
|
||||
if (warSchonMalVerbunden) melden("erfolg", "Verbindung zur Zentrale ist wieder da.")
|
||||
warSchonMalVerbunden = true
|
||||
// Beim (Wieder-)Verbinden einmal alles nachziehen: Was während der Trennung
|
||||
// passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (!verbunden) return
|
||||
verbunden = false
|
||||
// „nachlauf" statt „getrennt": EventSource reconnectet selbst, und bis dahin
|
||||
// pollen die Queries weiter — die Zahlen sind also älter, aber nicht tot.
|
||||
stromLageSetzen("nachlauf")
|
||||
melden("warnung", "Verbindung zur Zentrale verloren — die Anzeigen laufen im Nachlauf.")
|
||||
// Sofort auf den normalen Takt zurück, statt bis zum nächsten Refetch zu warten.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
stromEreignis()
|
||||
} catch { /* kaputtes Event bricht den Strom nicht */ }
|
||||
})
|
||||
return () => { verbunden = false; es.close() }
|
||||
|
||||
return () => {
|
||||
verbunden = false
|
||||
stromLageSetzen("getrennt")
|
||||
es.close()
|
||||
}
|
||||
}, [qc])
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
// Globaler „Einfach/Experte"-Schalter. Einfach (Default) blendet technische
|
||||
// Details (Kontext, Spec, Slots, Ko-Residenz, Lane-Routing …) aus, damit die UI
|
||||
// nicht überfordert. In localStorage gemerkt; alle Consumer aktualisieren via Event.
|
||||
|
||||
const KEY = "mc_expert_mode"
|
||||
const EVT = "mc-expert-mode-change"
|
||||
|
||||
const read = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(KEY) === "true"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
window.addEventListener(EVT, cb)
|
||||
window.addEventListener("storage", cb)
|
||||
return () => {
|
||||
window.removeEventListener(EVT, cb)
|
||||
window.removeEventListener("storage", cb)
|
||||
}
|
||||
}
|
||||
|
||||
export function setExpertMode(v: boolean) {
|
||||
localStorage.setItem(KEY, String(v))
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
}
|
||||
|
||||
export function useExpertMode(): boolean {
|
||||
return useSyncExternalStore(subscribe, read, () => false)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
@@ -9,6 +10,7 @@ import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
const ALIASES = ["chat", "coding", "fast", "heavy"]
|
||||
|
||||
export function AgentView() {
|
||||
const navigate = useNavigate()
|
||||
const { data: s, error: sErr } = useAgentStatus()
|
||||
const { data: modelsData } = useModels()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
@@ -240,7 +242,7 @@ export function AgentView() {
|
||||
<strong>Hermes-Gateway</strong> mit einem Klick neu starten (dort gibt es auch die Logs dazu).
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))}
|
||||
onClick={() => navigate({ to: ".", search: (a) => ({ ...a, system: "wartung" as const }) })}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-[11px] font-bold uppercase tracking-wide text-primary transition-all hover:bg-primary/20 cursor-pointer">
|
||||
<Wrench className="h-3.5 w-3.5" /> Dienste-Übersicht öffnen
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
|
||||
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, ArrowRight,
|
||||
} from "lucide-react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { api, type AuftragItem, type SkillKandidat } from "@/lib/api"
|
||||
import { useAuftragsbuch, useIdeen, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
@@ -33,6 +34,7 @@ const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> =
|
||||
|
||||
|
||||
export function AuftragsbuchView() {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading, refetch, isFetching } = useAuftragsbuch()
|
||||
// Geteilter Cache mit der IdeenSection — hier nur fürs ehrliche "Nichts zu entscheiden"-
|
||||
// Banner (eine hängende Karte direkt darunter widerspräche dem grünen Häkchen).
|
||||
@@ -105,7 +107,7 @@ export function AuftragsbuchView() {
|
||||
|
||||
{blockedIdeen > 0 && (
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("mc-navigate", { detail: { view: "ideen" } }))}
|
||||
onClick={() => navigate({ to: "/ideen" })}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-red-500/30 bg-red-500/5 p-4 text-left transition-all hover:border-red-500/50 cursor-pointer">
|
||||
<Lightbulb className="h-5 w-5 shrink-0 text-red-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SquareTerminal, ExternalLink, AlertTriangle, RefreshCw } from "lucide-r
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useQueryClient, qk, invalidate } from "@/lib/queries"
|
||||
import { resolveExternalUrl, cn } from "@/lib/utils"
|
||||
import { melden } from "@/app/store"
|
||||
|
||||
// Konsole = direkter, SSH-artiger Box-Zugriff (ttyd → Login-Shell auf :7682), eingebettet
|
||||
// per iframe — Schwester der Hermes-Terminal-Seite. Login per Box-Passwort (su-Prompt im
|
||||
@@ -23,10 +24,16 @@ export function KonsoleView() {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST", body: JSON.stringify({ service: "box-console" }),
|
||||
})
|
||||
setRestartMsg(r.ok ? "Neu gestartet — einen Moment, dann lädt das Terminal." : `Fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`)
|
||||
const text = r.ok
|
||||
? "Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal."
|
||||
: `Neustart fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`
|
||||
setRestartMsg(text)
|
||||
melden(r.ok ? "erfolg" : "fehler", text)
|
||||
invalidate(qc, qk.agentStatus, qk.services)
|
||||
} catch (e: any) {
|
||||
setRestartMsg(`Fehlgeschlagen: ${e?.message || e}`)
|
||||
const text = `Neustart fehlgeschlagen: ${e?.message || e}`
|
||||
setRestartMsg(text)
|
||||
melden("fehler", text)
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
|
||||
Inbox, HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { api } from "@/lib/api"
|
||||
import {
|
||||
useModels, useServices, useUpdates, useAgentStatus, useConnectHealth,
|
||||
@@ -17,14 +18,20 @@ import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
|
||||
import { LatencyCard } from "@/components/dashboard/LatencyCard"
|
||||
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
||||
|
||||
// Öffnet die bestehende System-Schublade (Pflege/Logs) via globalem Event.
|
||||
const openDrawer = (tab: "maintenance" | "logs") =>
|
||||
window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab } }))
|
||||
// Öffnet die System-Schublade — v3-Umbau P3: über die URL (?system=), nicht mehr über
|
||||
// einen globalen CustomEvent. Der Zustand ist damit teilbar und überlebt einen Reload;
|
||||
// vorher war er ein useState in der App, das man nur von außen anstoßen konnte.
|
||||
function useSchublade() {
|
||||
const navigate = useNavigate()
|
||||
return (tab: "wartung" | "logs") =>
|
||||
navigate({ to: ".", search: (a) => ({ ...a, system: tab }) })
|
||||
}
|
||||
|
||||
// Cockpit = die Startseite. Ruhige Übersicht: ehrliche Status-Zeile oben, „Braucht
|
||||
// dich"-Bereich für offene Aktionen, darunter die Bereichs-Kacheln (je EINE Live-Zahl).
|
||||
// Überblick außen, Tiefe hinter der Kachel — Daten/Aktionen aus den IST-Hooks.
|
||||
export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void }) {
|
||||
const openDrawer = useSchublade()
|
||||
const { data: models } = useModels()
|
||||
const { data: svc } = useServices()
|
||||
const { data: updates } = useUpdates()
|
||||
@@ -147,14 +154,14 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
|
||||
icon={Server} title="Dienste" value={svc ? `${svcOk}/${svcTotal}` : "…"} unit="laufen"
|
||||
tone={!svc ? "loading" : svcErrors > 0 ? "warn" : "ok"}
|
||||
hint={failedSvc ? `Ausfall: ${failedSvc}` : "Alle Dienste online"}
|
||||
onClick={() => openDrawer("maintenance")}
|
||||
onClick={() => openDrawer("wartung")}
|
||||
/>
|
||||
<CockpitTile
|
||||
icon={ShieldAlert} title="Updates" value={updates ? (pendingCount > 0 ? pendingCount : "0") : "…"}
|
||||
unit={pendingCount > 0 ? "bereit" : "aktuell"}
|
||||
tone={!updates ? "loading" : pendingCount > 0 ? "warn" : "ok"}
|
||||
hint={pendingCount > 0 ? `Verfügbar: ${updateSummary}` : "Alles auf dem neuesten Stand"}
|
||||
onClick={() => openDrawer("maintenance")}
|
||||
onClick={() => openDrawer("wartung")}
|
||||
/>
|
||||
<CockpitTile
|
||||
icon={Plug} title="Verbinden" value={connect ? `${connectOkCount}/${connectLines.length}` : "…"}
|
||||
@@ -263,6 +270,7 @@ function NeedsYou({
|
||||
onRepair: (id: string, r: Repair) => void
|
||||
onNavigate: (v: string) => void
|
||||
}) {
|
||||
const openDrawer = useSchublade()
|
||||
const hasUpdates = pendingCount > 0
|
||||
const hasAuftraege = openAuftraege > 0
|
||||
const hasBlocked = blockedIdeen > 0
|
||||
@@ -339,7 +347,7 @@ function NeedsYou({
|
||||
)}
|
||||
{hasUpdates && (
|
||||
<button
|
||||
onClick={() => openDrawer("maintenance")}
|
||||
onClick={() => openDrawer("wartung")}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-left transition-all hover:bg-amber-500/10 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -365,6 +373,7 @@ function ToolListCard({ agent, svcErrors, onNavigate }: {
|
||||
svcErrors: number
|
||||
onNavigate: (v: string) => void
|
||||
}) {
|
||||
const openDrawer = useSchublade()
|
||||
const tools = [
|
||||
{
|
||||
label: "Hermes Agent",
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { api, type ModelInfo } from "@/lib/api"
|
||||
import { useModels, useSystemStatus, useGroups, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { useExpertMode } from "@/lib/useExpertMode"
|
||||
import { useExpertenmodus } from "@/app/store"
|
||||
import { CapsChips } from "@/components/CapsChips"
|
||||
import { RoleLabel, getBrandInfo } from "@/components/models/ModelBadges"
|
||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||
@@ -28,7 +28,7 @@ export function ModelsWorkbench() {
|
||||
const { data: sys } = useSystemStatus()
|
||||
const { data: groupsResp } = useGroups()
|
||||
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
||||
const expert = useExpertMode()
|
||||
const expert = useExpertenmodus()
|
||||
|
||||
const models = modelsResp?.models ?? []
|
||||
const running = modelsResp?.running ?? []
|
||||
|
||||
Reference in New Issue
Block a user