feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL

Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.

## Was jetzt in der Adresse steht (Befund B-05)

  /modelle?reiter=routing     der Reiter war lokaler State, nicht teilbar
  /ideen?offen=<id>           die aufgeklappte Karte; wer waehrend eines
                              laufenden Workers neu lud, verlor seinen Platz
  /wissen?datei=<pfad>        jede Vault-Notiz verlinkbar
  <ueberall>?system=wartung|logs   die System-Schublade inkl. Reiter

Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.

## Alte Lesezeichen brechen nicht

lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.

Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.

## Fehlergrenze pro Route (Befund B-13)

Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.

## Budget: der Router kostete 29 kB, sie sind wieder drin

TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.

  Start-Chunk gzip  111 411 -> 114 978 B   (Budget 125 000, weiter scharf)
  dist gesamt                  1 565 521 B (Budget 3 145 728)

## Nebenbei

  · Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
    (`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
    Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
  · Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
    Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
  · nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
    damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.

## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)

  /                        -> /cockpit, Sidebar markiert
  /#models                 -> /modelle, Sidebar markiert
  /modelle?reiter=routing  -> Reiter "Routing & Warm-Set" aktiv
  /cockpit?system=logs     -> Schublade offen, Reiter "System-Logs" aktiv;
                              Schliessen entfernt den Parameter wieder
  /gibtsnicht              -> 404-Seite, Schiene + Kopfzeile intakt
  Zurueck-Taste            -> voriger Pfad

26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:55:58 +02:00
co-authored by Claude Opus 5
parent ae69c5ce31
commit 464b0d20b8
50 changed files with 1143 additions and 607 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-BYlMs6Dj.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
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};
+1
View File
@@ -0,0 +1 @@
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};
-1
View File
@@ -1 +0,0 @@
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-BYlMs6Dj.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as s,j as e}from"./index-BYlMs6Dj.js";/**
import{c as s,j as e}from"./index-Bb8GyPHo.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
+6
View File
@@ -0,0 +1,6 @@
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
View File
@@ -1,6 +0,0 @@
var M=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||M("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?M("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a9 as B,aa as F,ab as E,ac as I,ad as P,u as A,r as f,ae as J,af as Q,c as T,ag as U,j as s,G as D,T as H,b as K,L,M as G,q as V,n as R}from"./index-BYlMs6Dj.js";import{J as _}from"./JobsBar-B1nmMx5i.js";var c,g,o,h,m,w,C,q,z=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&E(t.mutationKey)!==E(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new z(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
* @license lucide-react v0.460.0 - ISC
*
* 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=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: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(G,{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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
import{c as r}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 o=r("ArrowRight",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]);export{o as A};
@@ -1,4 +1,4 @@
import{c as o}from"./index-BYlMs6Dj.js";/**
import{c as o}from"./index-Bb8GyPHo.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
+6
View File
@@ -0,0 +1,6 @@
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};
+6
View File
@@ -0,0 +1,6 @@
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 o=c("Clock",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["polyline",{points:"12 6 12 12 16 14",key:"68esgv"}]]);export{o as C};
@@ -1,4 +1,4 @@
import{c as e}from"./index-BYlMs6Dj.js";/**
import{c as e}from"./index-Bb8GyPHo.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
+6
View File
@@ -0,0 +1,6 @@
import{c as a}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 e=a("ExternalLink",[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]]);export{e as E};
+6
View File
@@ -0,0 +1,6 @@
import{c as e}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 t=e("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);export{t as F};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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{at as r}from"./index-Bb8GyPHo.js";var o=r();export{o as r};
@@ -1,4 +1,4 @@
import{c as a}from"./index-BYlMs6Dj.js";/**
import{c as a}from"./index-Bb8GyPHo.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
+6
View File
@@ -0,0 +1,6 @@
import{c as o}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 a=o("Power",[["path",{d:"M12 2v10",key:"mnfbl"}],["path",{d:"M18.4 6.6a9 9 0 1 1-12.77.04",key:"obofu9"}]]);export{a as P};
+6
View File
@@ -0,0 +1,6 @@
import{c as e}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 t=e("RefreshCw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);export{t as R};
-6
View File
@@ -1,6 +0,0 @@
import{c as t}from"./index-BYlMs6Dj.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 e=t("RotateCcw",[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]]);export{e as R};
+6
View File
@@ -0,0 +1,6 @@
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("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);export{r as S};
+6
View File
@@ -0,0 +1,6 @@
import{c as a}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 c=a("Shield",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}]]);export{c as S};
@@ -1,4 +1,4 @@
import{c as a}from"./index-BYlMs6Dj.js";/**
import{c as a}from"./index-Bb8GyPHo.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
+2 -2
View File
@@ -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-BYlMs6Dj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8N0vA58P.css">
<script type="module" crossorigin src="/assets/index-Bb8GyPHo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DErfN6d3.css">
</head>
<body>
<div id="root"></div>
+120
View File
@@ -12,6 +12,7 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "^1.170.32",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
@@ -2340,6 +2341,19 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/history": {
"version": "1.162.1",
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.1.tgz",
"integrity": "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==",
"license": "MIT",
"engines": {
"node": ">=20.19"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
@@ -2366,6 +2380,76 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-router": {
"version": "1.170.32",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.32.tgz",
"integrity": "sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.162.1",
"@tanstack/react-store": "^0.9.3",
"@tanstack/router-core": "1.171.27",
"isbot": "^5.1.22"
},
"engines": {
"node": ">=20.19"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=18.0.0 || >=19.0.0",
"react-dom": ">=18.0.0 || >=19.0.0"
}
},
"node_modules/@tanstack/react-store": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz",
"integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==",
"license": "MIT",
"dependencies": {
"@tanstack/store": "0.9.3",
"use-sync-external-store": "^1.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.171.27",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.27.tgz",
"integrity": "sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.162.1",
"cookie-es": "^3.0.0",
"seroval": "^1.6.2",
"seroval-plugins": "^1.6.2"
},
"engines": {
"node": ">=20.19"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/store": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz",
"integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -3354,6 +3438,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie-es": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4401,6 +4491,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/isbot": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz",
"integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==",
"license": "Unlicense",
"engines": {
"node": ">=18"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -5568,6 +5667,27 @@
"semver": "bin/semver.js"
}
},
"node_modules/seroval": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.4.tgz",
"integrity": "sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/seroval-plugins": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.4.tgz",
"integrity": "sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "^1.170.32",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
+88 -74
View File
@@ -1,29 +1,22 @@
import { useEffect, useState, useCallback, lazy, Suspense } from "react"
import { useEffect, useState, useCallback, Suspense, lazy } from "react"
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router"
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { NAV, navFuerPfad } from "@/nav"
import { ExpertToggle } from "@/components/ExpertToggle"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import type { Health, SystemStatus } from "@/lib/api"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { useEventStream } from "@/lib/events"
import { cn } from "@/lib/utils"
import { CockpitView } from "@/views/cockpit/CockpitView"
// Code-Splitting: nur das Cockpit (Startseite) lädt sofort, jede weitere View
// kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2):
// die Schublade zieht Wartung, Logs, Einstellungen und den Update-Dialog mit, die Palette
// bringt cmdk mit. Zusammen waren das rund 30 kB gzip, die niemand beim ersten Bild braucht.
// Der Router kostete P2 ungefaehr dasselbe — so bleibt das Buendel-Budget der Ampel scharf,
// statt es einfach hochzusetzen.
const SystemDrawer = lazy(() => import("@/components/SystemDrawer").then((m) => ({ default: m.SystemDrawer })))
const CommandPalette = lazy(() => import("@/components/CommandPalette").then((m) => ({ default: m.CommandPalette })))
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
function ViewLoading() {
@@ -37,22 +30,50 @@ function ViewLoading() {
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
export default function App() {
/**
* Die Shell: Schiene, Kopfzeile, System-Schublade alles, was über JEDER Ansicht liegt.
*
* v3-Umbau P2 (28.08.2026): Hier stand vorher der Ansichts-Umschalter
* (`{view === "ideen" && <IdeenView />}`), gespeist aus einem `useState`, das am URL-Hash
* hing. Das funktionierte für Reload und Lesezeichen aber nur auf View-Ebene. Jetzt
* rendert der Router in den `<Outlet/>`, und der Zustand DARUNTER (offene Idee, Reiter,
* Schublade) liegt in echten Suchparametern. Siehe app/router.tsx.
*/
export function Shell() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
const [view, setView] = useState<ViewId>(() => {
const h = window.location.hash.slice(1) as ViewId
return NAV.some((n) => n.id === h) ? h : "dashboard"
const navigate = useNavigate()
const pfad = useRouterState({ select: (s) => s.location.pathname })
// Die Schublade liegt über allem und gehört deshalb in die Wurzel-Suche (?system=).
// Vorher war sie lokaler State und wurde per CustomEvent von außen aufgestoßen —
// nicht teilbar, nicht reload-fest.
const drawerTab = useRouterState({
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
})
const navigate = useCallback((v: ViewId) => {
if (window.location.hash.slice(1) === v) setView(v)
else window.location.hash = v
}, [])
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// 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.
const [paletteBereit, setPaletteBereit] = useState(false)
useEffect(() => {
const wecken = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") setPaletteBereit(true)
}
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)
}
}, [])
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
navigate({ to: ".", search: (alt: Record<string, unknown>) => ({ ...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.
@@ -63,38 +84,33 @@ export default function App() {
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 customEvent = e as CustomEvent
setDrawerTab(customEvent.detail?.tab || "maintenance")
setDrawerOpen(true)
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 ViewId | undefined
if (v) navigate(v)
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])
useEffect(() => {
const onHash = () => {
const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h)
}
window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash)
}, [])
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
useEffect(() => { setMobileNavOpen(false) }, [view])
useEffect(() => { setMobileNavOpen(false) }, [pfad])
const active = NAV.find((n) => n.id === view)!
const active = navFuerPfad(pfad)
return (
<div className="flex h-full relative">
@@ -112,8 +128,17 @@ export default function App() {
}}
/>
<CommandPalette onNavigate={navigate} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
<Suspense fallback={null}>
{paletteBereit && <CommandPalette />}
{drawerTab && (
<SystemDrawer
open
onClose={() => schubladeSetzen(undefined)}
defaultTab={drawerTab === "logs" ? "logs" : "maintenance"}
/>
)}
</Suspense>
{/* Sidebar (Desktop, ab md) */}
<aside className={cn(
@@ -129,7 +154,7 @@ export default function App() {
return next
})
}}
view={view}
pfad={pfad}
health={health}
backendDown={backendDown}
sysStatus={sysStatus}
@@ -152,7 +177,7 @@ export default function App() {
<SidebarContent
collapsed={false}
onClose={() => setMobileNavOpen(false)}
view={view}
pfad={pfad}
health={health}
backendDown={backendDown}
sysStatus={sysStatus}
@@ -171,7 +196,7 @@ export default function App() {
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active?.hint}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<ExpertToggle />
@@ -186,10 +211,7 @@ export default function App() {
</a>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev)
}}
onClick={() => window.dispatchEvent(new CustomEvent("mc-palette-open"))}
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" />
@@ -201,18 +223,7 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
<Suspense fallback={<ViewLoading />}>
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "ideen" && <IdeenView />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "skills" && <SkillsView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "agent" && <AgentView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
<Outlet />
</Suspense>
</main>
</div>
@@ -223,16 +234,18 @@ export default function App() {
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
function SidebarContent({
collapsed, onToggleCollapse, onClose, view, health, backendDown, sysStatus,
collapsed, onToggleCollapse, onClose, pfad, health, backendDown, sysStatus,
}: {
collapsed: boolean
onToggleCollapse?: () => void
onClose?: () => void
view: ViewId
pfad: string
health: Health | undefined
backendDown: boolean
sysStatus: SystemStatus | undefined
}) {
const aktiv = navFuerPfad(pfad)
return (
<>
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
@@ -272,6 +285,7 @@ function SidebarContent({
return NAV.map((item) => {
const showGroupHeader = item.group !== lastGroup
lastGroup = item.group
const istAktiv = aktiv?.id === item.id
return (
<div key={item.id} className="space-y-1">
{showGroupHeader && (
@@ -283,14 +297,14 @@ function SidebarContent({
</div>
)
)}
<a
href={`#${item.id}`}
<Link
to={item.pfad}
onClick={onClose}
aria-current={view === item.id ? "page" : undefined}
aria-current={istAktiv ? "page" : undefined}
className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id
istAktiv
? (collapsed ? "nav-active-collapsed" : "nav-active")
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
@@ -299,7 +313,7 @@ function SidebarContent({
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!collapsed && <span className="truncate">{item.label}</span>}
</a>
</Link>
</div>
)
})
+187
View File
@@ -0,0 +1,187 @@
import { lazy } from "react"
import {
createRootRoute,
createRoute,
createRouter,
redirect,
useNavigate,
} from "@tanstack/react-router"
import { Shell } from "@/App"
import { NAV } from "@/nav"
import { RouteFehler } from "@/components/RouteFehler"
import { CockpitView } from "@/views/cockpit/CockpitView"
// Routenbaum (v3-Umbau P2).
//
// Bewusst code-based statt dateibasiert: Es sind zwölf Routen, und sie stehen dadurch
// alle an EINER Stelle — inklusive dem, was in den Suchparametern erlaubt ist. Eine
// Datei-Konvention würde diese Übersicht auf zwölf Dateien verteilen, ohne dass jemand
// etwas davon hätte.
//
// Code-Splitting bleibt exakt wie bisher: die Views hängen an React.lazy, das Cockpit als
// Startseite lädt eifrig. Kein Chunk verschiebt sich durch den Router.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// ── Suchparameter ───────────────────────────────────────────────────────────
// Regel für das, was hier steht: Alles, was ein Kollege per Link teilen können soll und
// was einen Reload überleben muss. Nichts Flüchtiges (Hover, Entwürfe im Eingabefeld).
/** Wurzel: die System-Schublade. Gehört hierher, weil sie über JEDER Ansicht liegt. */
export interface WurzelSuche {
system?: "wartung" | "logs"
}
const wurzelSuche = (roh: Record<string, unknown>): WurzelSuche => {
const s = roh.system
return s === "wartung" || s === "logs" ? { system: s } : {}
}
export const rootRoute = createRootRoute({
component: Shell,
validateSearch: wurzelSuche,
notFoundComponent: () => (
<RouteFehler
titel="Diese Seite gibt es nicht"
text="Der Link zeigt auf eine Ansicht, die Mission Control nicht kennt. Vielleicht stammt er aus einer älteren Fassung."
/>
),
})
// Wurzelpfad → Cockpit. `redirect` statt einer Kopie der Startseite: sonst gäbe es
// zwei URLs für denselben Inhalt und die aktive Markierung in der Sidebar flackert.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/cockpit" })
},
})
/** Eine Route je Ansicht kurze Schreibweise, weil sich nur Pfad und Bauteil ändern.
* Generisch über den Pfad, damit TanStack Router den Literal-Typ behält: mit
* `path: string` verlöre `navigate({ to: "/cockpit" })` seine Typprüfung. */
function ansicht<P extends string>(path: P, component: () => React.ReactNode) {
return createRoute({
getParentRoute: () => rootRoute,
path,
component,
// Fehlergrenze PRO Route (Befund B-13): Vorher hing eine einzige Grenze in main.tsx
// um die ganze App — ein Renderfehler in den Modellen nahm Cockpit, Konsole und
// Chronik mit. Jetzt bleiben Schiene, Kopfzeile und Statusanzeige stehen.
errorComponent: ({ error, reset }) => (
<RouteFehler
titel="Diese Ansicht ist abgestürzt"
text="Der Rest von Mission Control läuft weiter. Erneut versuchen — oder in eine andere Ansicht wechseln."
fehler={error}
onErneut={reset}
/>
),
})
}
// Das Cockpit verlinkt in andere Bereiche und bekam den Sprung bisher als Prop von der
// App gereicht. Jetzt kommt er aus dem Router — die Ansicht selbst bleibt unverändert.
function CockpitSeite() {
const navigate = useNavigate()
return <CockpitView onNavigate={(v) => {
const ziel = NAV.find((n) => n.id === v)
if (ziel) navigate({ to: ziel.pfad })
}} />
}
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />)
const skillsRoute = ansicht("/skills", () => <SkillsView />)
const chronikRoute = ansicht("/chronik", () => <ChronikView />)
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
const agentRoute = ansicht("/agent", () => <AgentView />)
/** Ideen: `?offen=<id>` hält die aufgeklappte Karte fest (Befund B-05).
* Vorher lag das in `useState` wer während eines laufenden Workers neu lud,
* verlor seinen Platz in der Liste. */
export interface IdeenSuche {
offen?: string
}
const ideenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/ideen",
validateSearch: (roh: Record<string, unknown>): IdeenSuche =>
typeof roh.offen === "string" && roh.offen ? { offen: roh.offen } : {},
component: () => <IdeenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Ideen-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Wissen: `?datei=<pfad>` macht jede Vault-Notiz verlinkbar. */
export interface WissenSuche {
datei?: string
}
const wissenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/wissen",
validateSearch: (roh: Record<string, unknown>): WissenSuche =>
typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {},
component: () => <WissenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Modelle: `?reiter=` — der Reiter war lokaler State und damit nicht teilbar. */
export type ModellReiter = "werkbank" | "routing" | "discover"
export interface ModelleSuche {
reiter?: ModellReiter
}
const modelleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/modelle",
validateSearch: (roh: Record<string, unknown>): ModelleSuche => {
const r = roh.reiter
return r === "werkbank" || r === "routing" || r === "discover" ? { reiter: r } : {}
},
component: () => <ModelsView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Der Modell-Manager ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
export const routeTree = rootRoute.addChildren([
indexRoute,
cockpitRoute,
ideenRoute,
auftraegeRoute,
skillsRoute,
chronikRoute,
wissenRoute,
verbindenRoute,
konsoleRoute,
anleitungRoute,
modelleRoute,
agentRoute,
])
export const router = createRouter({
routeTree,
// Ein LAN-Gerät: der Verlauf muss nicht scrollen-restaurieren, aber die Position
// oben zu haben ist beim Ansichtswechsel richtig.
scrollRestoration: true,
defaultPreload: false,
})
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
+15 -3
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"
import { Command } from "cmdk"
import { NAV, type ViewId } from "@/nav"
import { useNavigate } from "@tanstack/react-router"
import { NAV } from "@/nav"
export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void }) {
export function CommandPalette() {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -16,6 +18,16 @@ export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void
return () => document.removeEventListener("keydown", onKey)
}, [])
// Der „Suchen"-Knopf in der Kopfzeile (v3-Umbau P2, Befund B-09).
// Er baute vorher ein GEFÄLSCHTES Tastatur-Ereignis (`new KeyboardEvent("keydown",
// { metaKey: true })`) und feuerte es aufs Dokument, damit der Zuhörer oben anspringt.
// Jetzt ein eigener, ehrlicher Kanal. In P3 löst der Store auch den ab.
useEffect(() => {
const onOpen = () => setOpen(true)
window.addEventListener("mc-palette-open", onOpen)
return () => window.removeEventListener("mc-palette-open", onOpen)
}, [])
return (
<Command.Dialog
open={open}
@@ -43,7 +55,7 @@ export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void
key={item.id}
value={`${item.label} ${item.hint}`}
onSelect={() => {
onNavigate(item.id)
navigate({ to: item.pfad })
setOpen(false)
}}
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
+61
View File
@@ -0,0 +1,61 @@
import { AlertTriangle, RotateCcw } from "lucide-react"
// Fehlerfläche EINER Ansicht (v3-Umbau P2, Befund B-13).
//
// Vorher hing genau eine Fehlergrenze in main.tsx um die gesamte App: Ein Renderfehler
// im Modell-Manager nahm Cockpit, Konsole und Chronik gleich mit — die Seite war weiß.
// Jetzt fängt jede Route für sich; Schiene, Kopfzeile und Statusanzeige bleiben stehen,
// und der Nutzer kann einfach woanders hin.
//
// Die Meldung sagt, was passiert ist und was man tun kann. Der technische Text steht
// aufklappbar darunter — nicht versteckt (man braucht ihn für einen Fehlerbericht),
// aber auch nicht als Erstes im Gesicht.
export function RouteFehler({ titel, text, fehler, onErneut }: {
titel: string
text: string
fehler?: unknown
onErneut?: () => void
}) {
const detail = fehler instanceof Error ? (fehler.stack || fehler.message) : fehler ? String(fehler) : null
return (
<div className="flex min-h-[50vh] items-center justify-center p-6">
<div className="mc-card max-w-lg space-y-4 p-6">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
<h1 className="font-space text-lg font-bold tracking-tight">{titel}</h1>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
<div className="flex flex-wrap gap-2 pt-1">
{onErneut && (
<button
onClick={onErneut}
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Erneut versuchen
</button>
)}
<a
href="/cockpit"
className="flex h-9 items-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
>
Zum Cockpit
</a>
</div>
{detail && (
<details className="pt-1">
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
Technische Einzelheiten
</summary>
<pre className="scrollbar-thin mt-2 max-h-52 overflow-auto rounded-lg border border-border/40 bg-background/40 p-3 font-mono text-[10px] leading-relaxed text-muted-foreground">
{detail}
</pre>
</details>
)}
</div>
</div>
)
}
+3 -2
View File
@@ -311,9 +311,10 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors"
aria-label="System-Schublade schließen"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from "vitest"
import { hashUmleiten } from "./hashUmleitung"
import { NAV, navFuerPfad } from "@/nav"
function adresseSetzen(url: string) {
window.history.replaceState(null, "", url)
}
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
// Tests machen sichtbar, was dabei kaputtginge.
describe("hashUmleiten()", () => {
beforeEach(() => adresseSetzen("/"))
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
for (const eintrag of NAV) {
adresseSetzen("/#" + eintrag.id)
hashUmleiten()
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
}
})
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
adresseSetzen("/#/models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
})
it("behält vorhandene Suchparameter", () => {
adresseSetzen("/?system=logs#models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
expect(window.location.search).toBe("?system=logs")
})
it("fasst einen unbekannten Hash nicht an", () => {
adresseSetzen("/cockpit#irgendwas")
hashUmleiten()
expect(window.location.pathname).toBe("/cockpit")
})
it("tut ohne Hash gar nichts", () => {
adresseSetzen("/modelle?reiter=routing")
hashUmleiten()
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
})
})
describe("navFuerPfad()", () => {
it("findet den Eintrag zum exakten Pfad", () => {
expect(navFuerPfad("/modelle")?.id).toBe("models")
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
})
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
})
it("liefert nichts für einen unbekannten Pfad", () => {
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
})
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
const treffer = navFuerPfad("/modelle")
expect(treffer?.pfad).toBe("/modelle")
})
})
+26
View File
@@ -0,0 +1,26 @@
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
//
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
//
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
//
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
import { NAV, type ViewId } from "@/nav"
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
NAV.map((n) => [n.id, n.pfad]),
) as Record<ViewId, string>
export function hashUmleiten(): void {
const hash = window.location.hash.replace(/^#\/?/, "")
if (!hash) return
const ziel = ALT_ZU_PFAD[hash as ViewId]
if (!ziel) return
window.history.replaceState(null, "", ziel + window.location.search)
}
+13 -4
View File
@@ -1,13 +1,19 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App"
import { RouterProvider } from "@tanstack/react-router"
import { router } from "./app/router"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import { hashUmleiten } from "./lib/hashUmleitung"
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
import "./index.css"
// Muss VOR dem ersten Rendern hängen: Nach einem Deploy sucht ein offener Tab
// Nachlade-Chunks des alten Builds, die es nicht mehr gibt (siehe lib/veralteteVersion.ts).
// Beides MUSS vor dem ersten Rendern laufen:
// · hashUmleiten — alte Lesezeichen (#models) landen auf dem neuen Pfad (/modelle),
// bevor der Router die Adresse zum ersten Mal liest (P2).
// · veralteteVersionAbfangen — nach einem Deploy sucht ein offener Tab Nachlade-Chunks
// des alten Builds, die es nicht mehr gibt (P1).
hashUmleiten()
veralteteVersionAbfangen()
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
@@ -21,11 +27,14 @@ const queryClient = new QueryClient({
},
})
// AppErrorBoundary bleibt als LETZTES Netz stehen — für Fehler außerhalb jeder Route
// (Provider, Router selbst). Innerhalb der Routen fängt seit P2 jede Ansicht für sich
// (app/router.tsx → errorComponent), damit ein Absturz nicht die ganze Shell mitnimmt.
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
<RouterProvider router={router} />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>,
+55 -46
View File
@@ -1,46 +1,55 @@
import {
LayoutDashboard,
Boxes,
Inbox,
History,
Library,
Plug,
Bot,
SquareTerminal,
HelpCircle,
Lightbulb,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
export interface NavItem {
id: ViewId
label: string
hint: string
icon: LucideIcon
group: NavGroup
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
// Der Haupteingang: Idee → Konzept → Repo → Zed. Steht bewusst direkt unter dem
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
{ id: "ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
]
import {
LayoutDashboard,
Boxes,
Inbox,
History,
Library,
Plug,
Bot,
SquareTerminal,
HelpCircle,
Lightbulb,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
export interface NavItem {
id: ViewId
/** Echter URL-Pfad (v3-Umbau P2). Vorher war die Ansicht nur ein Hash (#models)
* teilbar auf View-Ebene, aber ohne Platz für den Zustand DARUNTER. */
pfad: string
label: string
hint: string
icon: LucideIcon
group: NavGroup
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", pfad: "/cockpit", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
// Der Haupteingang: Idee → Konzept → Repo → IDE. Steht bewusst direkt unter dem
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
{ id: "ideen", pfad: "/ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
{ id: "auftraege", pfad: "/auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "skills", pfad: "/skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
{ id: "chronik", pfad: "/chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "wissen", pfad: "/wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
{ id: "connect", pfad: "/verbinden", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
{ id: "konsole", pfad: "/konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
{ id: "guide", pfad: "/anleitung", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
{ id: "models", pfad: "/modelle", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
{ id: "agent", pfad: "/agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
]
/** Nav-Eintrag zu einem Pfad für Kopfzeile und aktive Markierung.
* Längster Treffer gewinnt, damit /ideen/<id> beim Eintrag Ideen" landet. */
export function navFuerPfad(pfad: string): NavItem | undefined {
return [...NAV]
.sort((a, b) => b.pfad.length - a.pfad.length)
.find((n) => pfad === n.pfad || pfad.startsWith(n.pfad + "/"))
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import {
Loader2, AlertTriangle, ChevronDown, ChevronRight, Clock, Hammer, Lightbulb, Send,
HelpCircle, RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork, X, Layers,
@@ -114,7 +115,14 @@ function IdeenSection() {
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [openLog, setOpenLog] = useState<string | null>(null)
// v3-Umbau P2 (Befund B-05): Die aufgeklappte Karte lag in useState. Wer waehrend
// eines laufenden Workers neu lud — und genau dann schaut man hin — verlor seinen
// Platz in der Liste. Jetzt steht sie in der URL (/ideen?offen=<id>) und ist teilbar.
const navigate = useNavigate()
const suche = useSearch({ from: "/ideen" })
const openLog = suche.offen ?? null
const setOpenLog = (id: string | null) =>
navigate({ to: "/ideen", search: (alt) => ({ ...alt, offen: id ?? undefined }), replace: true })
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
const [antworten, setAntworten] = useState<Record<string, string>>({})
+9 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { LaneEditor } from "@/components/models/LaneEditor"
@@ -16,7 +16,14 @@ const TABS = [
type TabId = (typeof TABS)[number]["id"]
export function ModelsView() {
const [tab, setTab] = useState<TabId>("werkbank")
// v3-Umbau P2: Der Reiter lag in useState und war damit nicht teilbar und nicht
// reload-fest — wer jemandem „schau dir das Routing an" schicken wollte, schickte
// die Werkbank. Jetzt steht er in der URL (/modelle?reiter=routing).
const navigate = useNavigate()
const { reiter } = useSearch({ from: "/modelle" })
const tab: TabId = reiter ?? "werkbank"
const setTab = (t: TabId) =>
navigate({ to: "/modelle", search: (alt) => ({ ...alt, reiter: t }), replace: true })
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">