1.4.3
All checks were successful
release-tag / release-image (push) Successful in 4m9s

This commit is contained in:
2026-08-26 22:35:13 +02:00
parent c7f1b006b5
commit c7bfe7036e
9 changed files with 305 additions and 12 deletions

View File

@@ -3,14 +3,15 @@
ed22fda7661db8203563611dc144998161cd024e161b0471d615eaf0defeb7db ./.gitea/workflows/release-tag.yml
e1ff71187cc3411a85067b964264011db7bd109a585ef7b9ea5b08bda039d813 ./.gitignore
9c18555764b03bdb004098bf6b8ca7f3eaebdccb8c17f15a11d2cebbab28cbd3 ./Makefile
52adec38e0256b594e84ddab42cb6694bd1649bf5e12dd6e94851a8b4cc01d10 ./README.md
4ce9d3f1bd0c4c95a3783d38fb69e1a6f302c88af3c71eea4980c6f722cce163 ./README.md
4858caa52c0fb6cf302a1c581d07d448e5e90e0daa797e5819610fd6223bd348 ./RELEASE-NOTES-v1.1.0.md
01163462f46314f57660677fdef407c6c2884412ea850aab13a4f650e8c29f50 ./RELEASE-NOTES-v1.2.0.md
4da388ce660aa3b7a0d8075ec066a025b5437960973397360fcb9a5d4cb58c96 ./RELEASE-NOTES-v1.3.0.md
257d6d3ea1d22b8ab97adb5d35ca5ac806702d75cea66982dc711754040fc7a2 ./RELEASE-NOTES-v1.4.0.md
0723a0151b72d492899a5c5409e1fef37de1fe4c458c3053f2f7eccd4b6e70c5 ./RELEASE-NOTES-v1.4.1.md
58923d3292f11487526b8dc4d6c0992025c0f946df8435fde5c91ef4581d46fd ./RELEASE-NOTES-v1.4.2.md
78b591400c56b7b67b8cb3b2b8a8e65e9093897f02ce0878e6b5405c68620fa7 ./VERSION
548f3168f8f29add4f510c4c8048a74465f311dbc3ba2436e5b8db5cc898c552 ./RELEASE-NOTES-v1.4.3.md
bd31e8d9cf8d14a1e9e3fdf3b9f6a524819aed0d1b14627523c6765767c7d8f1 ./VERSION
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./backups/.gitkeep
8127e9db5e5e0af1d88770dc8fa60b381de45dbcc843262698cf9501409b4d58 ./deploy/searxng/settings.yml
f95897d6e96afbbcc230f56ae1968a046f76cf24ec78d9c18aa2b22bd8335e34 ./docker-compose.yml
@@ -139,7 +140,7 @@ cb3cde795233c0d4842dc7451c5855d1c071e459efbde3be0cabac32a837ba1b ./knowledge/14
7f1d67faf4a6cea0c41c84d7b275d8a8979b19420b52db4925d8e2cd71ead3e0 ./knowledge/16_office-aktivierung-und-lizenzierung.json
f1eab883370e0a40ef52a6b6d785a510d8ed48a19d25e0a2bc95f4f2cc8e329e ./knowledge/17_serienbriefe-und-dokumentfunktionen.json
5a0b3d5d5bc712e30a67a4de3432f69070f7e43dd99931f675e72c213006b363 ./knowledge/example-vpn.json
e95d55331b2dfe8326ef4af50cee141d2c003a3f8a01eecaba028f1d4e6d3424 ./mega-project.json
e94bbbfe2769b3d30c69bdf4239b030d23423249cb3fa7f5de3171656cd3a908 ./mega-project.json
c02d6d702401343e6d7002c3db117935964e2abd18db966b604871e59715ee05 ./patches/SHA256SUMS
47a6fa2c79bbba0c04af86dfa65d58529f492c698060fe586456c22a4eadb877 ./patches/glpi-agent-mega.diff
9b411c90d96a86c80f088ee4637046eeefaf31c62059d11aa1a999d6eb08b5b4 ./patches/glpi-knowledge-mega.diff
@@ -149,6 +150,7 @@ f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 ./patches/neur
163a8a990aacd986bacb385494c2b2062ac10a9a8f0a456d19a6d1746c2fc500 ./patches/v1.3.0-to-v1.4.0.diff
654de4aac7cf318680872897c3de192afbafb61c7169acb8e7514116df86efde ./patches/v1.4.0-envfix-to-v1.4.1.diff
1ebc306bd6870389500c86cf07dcd677b5bd0448c762812c7e1c0f8732f36eb1 ./patches/v1.4.1-to-v1.4.2.diff
651c883facddfd0316f89e3cf3c24fd913371fcec2a5f447b8d105fa5dec463e ./patches/v1.4.2-to-v1.4.3.diff
564817f8edabde0c4e4a1a427a3aa5418aae7bf12e9463044a7e6e0f13973657 ./platform/neuroforge/.env.example
39319b6f2058e4c8d6656a9cf01675374f81a04075b956b093a2befb5e05ada4 ./platform/neuroforge/.gitignore
189486a885c7fb78e0eb878d93cda0c70ca6d7ff9bfdfb3f5f32487cf03a9688 ./platform/neuroforge/BENCHMARK-v0.5.0.md
@@ -204,8 +206,8 @@ edfedb67b1f55b2fa8a4a4d29b809b53083b36752e8dc087fa8603f4ec26154e ./platform/neu
65a8b8196343e7cfd9444ca314da4a83c81bc046d9478b8b93217d9cc68ba562 ./platform/neuroforge/internal/cost/cost.go
b9bb2934e01ed2bfb6b16e4139e10e51f1387112eb8654f7f692779a2fd2d273 ./platform/neuroforge/internal/cost/cost_test.go
fb66ce4ab760b979eacd4f7f17dedc41916f93c53ba582be572d250f99654695 ./platform/neuroforge/internal/httpapi/admin_app_auth_test.go
4071eb134f63f19c95bba709c505e3748fcb7613264b6876350f49e6b693d61b ./platform/neuroforge/internal/httpapi/httpapi.go
c5884789a3ae17be5d61367e5dc19f89a87ac83f9516e80310f66fc9e49f5103 ./platform/neuroforge/internal/httpapi/index.html
4b8555feb594fb71aac1c868eca00cc952eb4e998fbea10b442389f7d5e8160b ./platform/neuroforge/internal/httpapi/httpapi.go
b6df45ac405afddcd68d21a7f1cd7a5a2b4e2379d350eafa2589de6b1dc2540c ./platform/neuroforge/internal/httpapi/index.html
20f4a6cc30d5ce84ceed4fbdfc9f5c57ab5f82274e0c54464817d571e0e881b0 ./platform/neuroforge/internal/httpapi/integration.go
7f6e4767dfcb948570e05baadca6917f27a406fe57ef82eeb63e3b3979ec94b6 ./platform/neuroforge/internal/httpapi/integration_api_test.go
f8cfcc7a2bc781394231c25e36e75c41563f5250a1387e5687880d2591efd93b ./platform/neuroforge/internal/httpapi/integration_graph.go
@@ -220,6 +222,7 @@ c40412c77dd8635bbd4bf9c6d7eb11b0d7902aee896bdd1ee99e5cdfb72c7453 ./platform/neu
093f6c37c1021cb252501d2aa9c0f42a75444ed69748b455847623da122947b1 ./platform/neuroforge/internal/httpapi/outcomes_test.go
b3b1f5cfd6b0ec04341978898d72856af03a95b8922daf9864caeb27e37f1925 ./platform/neuroforge/internal/httpapi/research_live.go
b37f7ec3be867666999a9ec314139dd1bc4c74efb53edfa224b4fe8119858aac ./platform/neuroforge/internal/httpapi/research_live_test.go
4e65ab0a760369a6d6c4932c180ff6c67f0768d91e08ccf471f53c6ecd5d09be ./platform/neuroforge/internal/httpapi/status_dashboard_test.go
8a2251d4f4ae8114ac3f5e98fe2e1d6ce24e7817adcfbfc1f383d770e6ce139c ./platform/neuroforge/internal/httpapi/v3.go
b1587e066b56ef72f17162583698614d31c413e2ac7e75359b731c2e882a50ee ./platform/neuroforge/internal/httpapi/v4.go
1ef5cacb0652f2a6301c57f8987dd848fbe590abb73cab44899d9152ccce45d0 ./platform/neuroforge/internal/httpapi/v5.go

View File

@@ -1,6 +1,6 @@
# GLPI NeuroForge Mega v1.4.0
> Release: **v1.4.2** · Bugfix für leeren NeuroForge-Wissensraum-Graph.
> Release: **v1.4.3** · Dashboard-Status-Kompatibilitätsfix für korrekte Memory-/Synapsen-/Revision-Anzeige.
Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi.

24
RELEASE-NOTES-v1.4.3.md Normal file
View File

@@ -0,0 +1,24 @@
# GLPI NeuroForge Mega v1.4.3
## NeuroForge Dashboard status fix
Diese Patch-Version behebt eine Inkonsistenz zwischen der erweiterten `/admin/api/status`-Antwort und dem eingebetteten NeuroForge-Dashboard.
### Behoben
- `Memories` zeigte `0`, obwohl der Knowledge-Summary bereits semantische Memories meldete.
- `Synapsen` konnte fälschlich `0` anzeigen.
- die Seitenleiste zeigte `rev undefined`.
- die Betriebsansicht konnte HNSW-, PQ-, Job- und Maintenance-Werte als `0`/leer darstellen.
### Ursache
Der Status-Endpunkt liefert die schnellen Zähler seit der erweiterten Observability-Antwort unter `stats`, während ältere Dashboard-Teile weiterhin die ursprünglichen flachen Felder erwarteten.
### Lösung
- Der Server liefert die Status-Zähler wieder zusätzlich auf Top-Level und behält parallel das strukturierte `stats`-Objekt.
- Das WebUI normalisiert beide Antwortformen defensiv. Dadurch ist sowohl ein neues UI gegen einen älteren Server als auch ein älteres UI gegen den neuen Server robuster.
- Ein Regressionstest stellt sicher, dass die flachen Dashboard-Felder nicht erneut verschwinden.
Keine Datenmigration und keine `.env`-Änderung erforderlich.

View File

@@ -1 +1 @@
1.4.0
1.4.3

View File

@@ -28,7 +28,7 @@
"schema": "Wiki/Schema.md",
"glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI"
},
"version": "1.4.2",
"version": "1.4.3",
"controlled_learning": {
"raw_chat_auto_learning": false,
"validated_outcomes": [

View File

@@ -0,0 +1,191 @@
diff --git a/README.md b/README.md
index d92c1b8..aef1427 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# GLPI NeuroForge Mega v1.4.0
-> Release: **v1.4.2** · Bugfix für leeren NeuroForge-Wissensraum-Graph.
+> Release: **v1.4.3** · Dashboard-Status-Kompatibilitätsfix für korrekte Memory-/Synapsen-/Revision-Anzeige.
Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi.
diff --git a/RELEASE-NOTES-v1.4.3.md b/RELEASE-NOTES-v1.4.3.md
new file mode 100644
index 0000000..12ffe94
--- /dev/null
+++ b/RELEASE-NOTES-v1.4.3.md
@@ -0,0 +1,24 @@
+# GLPI NeuroForge Mega v1.4.3
+
+## NeuroForge Dashboard status fix
+
+Diese Patch-Version behebt eine Inkonsistenz zwischen der erweiterten `/admin/api/status`-Antwort und dem eingebetteten NeuroForge-Dashboard.
+
+### Behoben
+
+- `Memories` zeigte `0`, obwohl der Knowledge-Summary bereits semantische Memories meldete.
+- `Synapsen` konnte fälschlich `0` anzeigen.
+- die Seitenleiste zeigte `rev undefined`.
+- die Betriebsansicht konnte HNSW-, PQ-, Job- und Maintenance-Werte als `0`/leer darstellen.
+
+### Ursache
+
+Der Status-Endpunkt liefert die schnellen Zähler seit der erweiterten Observability-Antwort unter `stats`, während ältere Dashboard-Teile weiterhin die ursprünglichen flachen Felder erwarteten.
+
+### Lösung
+
+- Der Server liefert die Status-Zähler wieder zusätzlich auf Top-Level und behält parallel das strukturierte `stats`-Objekt.
+- Das WebUI normalisiert beide Antwortformen defensiv. Dadurch ist sowohl ein neues UI gegen einen älteren Server als auch ein älteres UI gegen den neuen Server robuster.
+- Ein Regressionstest stellt sicher, dass die flachen Dashboard-Felder nicht erneut verschwinden.
+
+Keine Datenmigration und keine `.env`-Änderung erforderlich.
diff --git a/VERSION b/VERSION
index 88c5fb8..428b770 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.0
+1.4.3
diff --git a/mega-project.json b/mega-project.json
index 32d52e5..1a449fb 100644
--- a/mega-project.json
+++ b/mega-project.json
@@ -28,7 +28,7 @@
"schema": "Wiki/Schema.md",
"glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI"
},
- "version": "1.4.2",
+ "version": "1.4.3",
"controlled_learning": {
"raw_chat_auto_learning": false,
"validated_outcomes": [
diff --git a/platform/neuroforge/internal/httpapi/httpapi.go b/platform/neuroforge/internal/httpapi/httpapi.go
index e6bf73c..dce944f 100644
--- a/platform/neuroforge/internal/httpapi/httpapi.go
+++ b/platform/neuroforge/internal/httpapi/httpapi.go
@@ -460,12 +460,18 @@ func (s *Server) adminStatus(w http.ResponseWriter, r *http.Request) {
"last_index": obs.ClusterLastIndex, "commit_index": obs.ClusterCommitIndex, "peers": obs.ClusterPeers, "voters": obs.ClusterVoters, "quorum": obs.ClusterQuorum,
"replicated_log": obs.ClusterLog,
}
- s.json(w, 200, map[string]any{
+ payload := map[string]any{
"stats": stats, "wal": s.store.WALStatus(),
"storage": map[string]any{"memory_segments": obs.Segments, "index_snapshot": map[string]any{"revision": obs.IndexSnapshotRevision, "deltas": obs.IndexDeltaCount, "segmented": cfg.Storage.IndexSegments.Enabled}, "tiering": tiering, "disk_ann": s.store.DiskANNStatus()},
"cluster": cluster, "cost": s.cost.Totals(), "providers": providers, "usage": s.store.RecentUsage(25),
"observability": obs, "runtime": currentRuntimeSnapshot(), "http": s.metrics.dashboardSnapshot(),
- })
+ }
+ // Keep the original flat status fields for dashboard and external-client
+ // compatibility while retaining the richer nested stats object.
+ for k, v := range stats {
+ payload[k] = v
+ }
+ s.json(w, 200, payload)
}
func (s *Server) adminGetConfig(w http.ResponseWriter, r *http.Request) {
s.json(w, 200, s.store.Config())
diff --git a/platform/neuroforge/internal/httpapi/index.html b/platform/neuroforge/internal/httpapi/index.html
index 95b9eb6..50e566d 100644
--- a/platform/neuroforge/internal/httpapi/index.html
+++ b/platform/neuroforge/internal/httpapi/index.html
@@ -148,6 +148,7 @@ function toast(msg,bad=false){let t=$('toast');t.textContent=msg;t.className='to
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
function safeHref(raw){try{let u=new URL(String(raw||''),location.origin);return (u.protocol==='http:'||u.protocol==='https:')?u.href:'#'}catch{return '#'}}
function fmtN(n){return Number(n||0).toLocaleString('de-DE')}function fmtBytes(n){n=Number(n||0);if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KiB';if(n<1073741824)return(n/1048576).toFixed(1)+' MiB';return(n/1073741824).toFixed(2)+' GiB'}
+function normalizeStatus(s){s=s&&typeof s==='object'?s:{};let st=s.stats&&typeof s.stats==='object'?s.stats:{};return {...s,...st}}
function when(d){if(!d)return'';let x=new Date(d),ms=x-Date.now(),a=Math.abs(ms),m=Math.round(a/60000);if(a<60000)return ms>0?'gleich':'gerade';if(m<60)return(ms>0?'in ':'vor ')+m+' min';let h=Math.round(m/60);return(ms>0?'in ':'vor ')+h+' h'}
function validHeaderToken(value){return typeof value==='string'&&/^[\x21-\x7E]+$/.test(value)}
function tokenHeaders(json=true){if(!admin)throw new Error('Admin-Token fehlt.');if(!validHeaderToken(admin))throw new Error('Admin-Token enthält Leerzeichen/Unicode. Bitte echten ASCII-Token neu eingeben.');let h={'X-Admin-Token':admin};if(json)h['Content-Type']='application/json';return h}
@@ -156,7 +157,7 @@ async function setToken(){let v=prompt('Admin-Token',admin||'');if(v===null)retu
function switchView(v){currentView=v;$$('.view').forEach(x=>x.classList.toggle('active',x.id==='view-'+v));$$('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view===v));$('pageTitle').textContent=viewMeta[v][0];$('pageSub').textContent=viewMeta[v][1];if(v!=='goals')stopResearchPolling();if(v==='knowledge')loadKnowledgeGraph();if(v==='sources')loadSources();if(v==='research')loadResearchConfig();if(v==='goals'){loadGoals();if(researchWatch.goalId)startResearchPolling()}if(v==='models')loadModels();if(v==='ops')loadOps()}
$$('[data-view]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view)));$('tokenBtn').addEventListener('click',setToken);$('refreshBtn').addEventListener('click',refreshAll);
-async function refreshAll(){if(!admin){$('sideStatus').textContent='Admin-Token setzen';return}try{let [s,k,g,src]=await Promise.all([req('/admin/api/status'),req('/admin/api/knowledge/summary'),req('/api/v1/goals'),req('/api/v1/sources?limit=8')]);$('sideDot').className='dot ok';$('sideStatus').textContent='bereit · rev '+s.revision;$('mMem').textContent=fmtN(s.memories);$('mSyn').textContent=fmtN(s.synapses);$('mSources').textContent=fmtN(k.sources||src.length);let active=(g||[]).filter(x=>x.status==='active');$('mGoals').textContent=active.length;$('mNextGoal').textContent=active.length?('nächster '+when(active.map(x=>x.next_cycle_at).filter(Boolean).sort()[0])):'keine aktiven Ziele';$('mTypes').textContent=Object.entries(k.by_type||{}).map(([a,b])=>a+' '+b).join(' · ');renderSystemState(s);renderEvents(k.recent_events||[],'dashEvents');renderDashboardGoals(active.slice(0,5));if(currentView==='ops')loadOps()}catch(e){$('sideDot').className='dot bad';$('sideStatus').textContent='Fehler';toast(e.message,true)}}
+async function refreshAll(){if(!admin){$('sideStatus').textContent='Admin-Token setzen';return}try{let [raw,k,g,src]=await Promise.all([req('/admin/api/status'),req('/admin/api/knowledge/summary'),req('/api/v1/goals'),req('/api/v1/sources?limit=8')]),s=normalizeStatus(raw);src=Array.isArray(src)?src:[];g=Array.isArray(g)?g:[];$('sideDot').className='dot ok';$('sideStatus').textContent='bereit · rev '+fmtN(s.revision);$('mMem').textContent=fmtN(s.memories);$('mSyn').textContent=fmtN(s.synapses);$('mSources').textContent=fmtN(k?.sources||src.length);let active=g.filter(x=>x.status==='active');$('mGoals').textContent=active.length;$('mNextGoal').textContent=active.length?('nächster '+when(active.map(x=>x.next_cycle_at).filter(Boolean).sort()[0])):'keine aktiven Ziele';$('mTypes').textContent=Object.entries(k?.by_type||{}).map(([a,b])=>a+' '+b).join(' · ');renderSystemState(s);renderEvents(k?.recent_events||[],'dashEvents');renderDashboardGoals(active.slice(0,5));if(currentView==='ops')loadOps()}catch(e){$('sideDot').className='dot bad';$('sideStatus').textContent='Fehler';toast(e.message,true)}}
function renderSystemState(s){let o=s.observability||{},m=s.maintenance||{};$('systemState').innerHTML=[['Index',`${s.index_mode||o.index_mode||''} · HNSW ${fmtN(s.hnsw_nodes)} · PQ ${fmtN(s.disk_pq_items)}`],['Jobs',fmtN(s.pending_jobs)+' ausstehend'],['Cluster',s.cluster?.enabled?(s.cluster.role+' · term '+s.cluster.term):'deaktiviert'],['Letzter Autonomie-Lauf',m.last_autonomy_run?new Date(m.last_autonomy_run).toLocaleString():''],['Letzter Fehler',m.last_error||'keiner']].map(([a,b])=>`<div class="item"><div class="itemTitle"><b>${esc(a)}</b><span class="muted small">${esc(b)}</span></div></div>`).join('')}
function renderEvents(xs,id){xs=Array.isArray(xs)?xs:[];$(id).innerHTML=xs.length?xs.map(e=>`<div class="item"><div class="itemTitle"><b>${esc(e.type)}</b><span class="pill">${esc(e.actor||'system')}</span></div><p>${esc(e.summary||'')}</p><div class="meta"><span>${new Date(e.created_at).toLocaleString()}</span>${e.reason?`<span>${esc(e.reason)}</span>`:''}</div></div>`).join(''):'<p class="muted">Noch keine Lernereignisse.</p>'}
function renderDashboardGoals(xs){$('dashGoals').innerHTML=xs.length?xs.map(g=>`<div class="item"><div class="itemTitle"><b>${esc(g.title)}</b><span class="pill ${g.auto_run?'good':''}">${g.auto_run?'AUTO':'MANUELL'}</span></div><div class="progress" style="margin:9px 0"><span style="width:${Math.round((g.progress||0)*100)}%"></span></div><div class="meta"><span>${Math.round((g.progress||0)*100)}%</span><span>${g.research_enabled?'Research an':'Research aus'}</span><span>${g.next_cycle_at?when(g.next_cycle_at):'nicht geplant'}</span></div></div>`).join(''):'<p class="muted">Keine aktiven Ziele.</p>'}
@@ -222,7 +223,7 @@ $('chatBtn').addEventListener('click',async()=>{let input=document.getElementByI
async function loadModels(){try{let d=await req('/admin/api/model-routing');$('modelJSON').value=JSON.stringify(d,null,2);let nodes=d.ollama||[];$('modelCards').innerHTML=nodes.map(n=>`<div class="item"><div class="itemTitle"><b>${esc(n.name||n.id)}</b><span class="pill ${n.enabled?'good':''}">${n.enabled?'aktiv':'aus'}</span></div><p class="mono small">${esc(n.base_url)}</p><div class="meta"><span>Chat: ${esc(n.chat_model)}</span><span>Embed: ${esc(n.embedding_model)}</span><span>ctx ${n.num_ctx||'default'}</span><span>think ${esc(n.think||'off')}</span></div></div>`).join('')}catch(e){toast(e.message,true)}}$('modelSaveBtn').addEventListener('click',async()=>{try{let d=JSON.parse($('modelJSON').value);await req('/admin/api/model-routing',{method:'PUT',body:JSON.stringify(d)});toast('Modelle & Routing gespeichert.');loadModels()}catch(e){toast(e.message,true)}});$('modelHealthBtn').addEventListener('click',async()=>{try{$('modelHealth').textContent='prüfe …';let d=await req('/admin/api/provider-health',{method:'POST',body:'{}'});$('modelHealth').textContent=JSON.stringify(d,null,2)}catch(e){$('modelHealth').textContent=e.message}});
// ---- Operations ----
-async function loadOps(){try{let [s,st,cfg]=await Promise.all([req('/admin/api/status'),req('/admin/api/storage'),req('/admin/api/config')]);$('opsIndex').innerHTML=`<div class="item">HNSW <b>${fmtN(s.hnsw_nodes)}</b></div><div class="item">Disk-PQ <b>${fmtN(s.disk_pq_items)}</b></div><div class="item">Mode <b>${esc(s.index_mode||'')}</b></div>`;$('opsStorage').innerHTML=`<div class="item">Revision <b>${fmtN(s.revision)}</b></div><div class="item">Segmente <b>${fmtN(st.segments?.files||st.memory_segments?.files||0)}</b></div><div class="item">Disk <b>${fmtBytes(st.segments?.bytes||st.memory_segments?.bytes||0)}</b></div>`;$('opsObs').innerHTML=`<div class="item">Pending jobs <b>${fmtN(s.pending_jobs)}</b></div><div class="item">Remote shards <b>${fmtN(s.remote_shards)}</b></div><div class="item">Letzter Fehler <span class="small">${esc(s.maintenance?.last_error||'keiner')}</span></div>`;$('configJSON').value=JSON.stringify(cfg,null,2)}catch(e){toast(e.message,true)}}$('configSaveBtn').addEventListener('click',async()=>{try{let c=JSON.parse($('configJSON').value);await req('/admin/api/config',{method:'PUT',body:JSON.stringify(c)});toast('Konfiguration gespeichert.')}catch(e){toast(e.message,true)}});$('checkpointBtn').addEventListener('click',()=>opPost('/admin/api/checkpoint','Checkpoint erstellt'));$('compactBtn').addEventListener('click',()=>opPost('/admin/api/storage/compact','Segmente kompaktiert'));$('pqBuildBtn').addEventListener('click',()=>opPost('/admin/api/index/disk/rebuild','PQ-Index neu gebaut'));$('indexMergeBtn').addEventListener('click',()=>opPost('/admin/api/index/merge','Index gemerged'));async function opPost(path,msg){try{await req(path,{method:'POST',body:'{}'});toast(msg);loadOps()}catch(e){toast(e.message,true)}}
+async function loadOps(){try{let [raw,st,cfg]=await Promise.all([req('/admin/api/status'),req('/admin/api/storage'),req('/admin/api/config')]),s=normalizeStatus(raw);$('opsIndex').innerHTML=`<div class="item">HNSW <b>${fmtN(s.hnsw_nodes)}</b></div><div class="item">Disk-PQ <b>${fmtN(s.disk_pq_items)}</b></div><div class="item">Mode <b>${esc(s.index_mode||'')}</b></div>`;$('opsStorage').innerHTML=`<div class="item">Revision <b>${fmtN(s.revision)}</b></div><div class="item">Segmente <b>${fmtN(st.segments?.files||st.memory_segments?.files||0)}</b></div><div class="item">Disk <b>${fmtBytes(st.segments?.bytes||st.memory_segments?.bytes||0)}</b></div>`;$('opsObs').innerHTML=`<div class="item">Pending jobs <b>${fmtN(s.pending_jobs)}</b></div><div class="item">Remote shards <b>${fmtN(s.remote_shards)}</b></div><div class="item">Letzter Fehler <span class="small">${esc(s.maintenance?.last_error||'keiner')}</span></div>`;$('configJSON').value=JSON.stringify(cfg,null,2)}catch(e){toast(e.message,true)}}$('configSaveBtn').addEventListener('click',async()=>{try{let c=JSON.parse($('configJSON').value);await req('/admin/api/config',{method:'PUT',body:JSON.stringify(c)});toast('Konfiguration gespeichert.')}catch(e){toast(e.message,true)}});$('checkpointBtn').addEventListener('click',()=>opPost('/admin/api/checkpoint','Checkpoint erstellt'));$('compactBtn').addEventListener('click',()=>opPost('/admin/api/storage/compact','Segmente kompaktiert'));$('pqBuildBtn').addEventListener('click',()=>opPost('/admin/api/index/disk/rebuild','PQ-Index neu gebaut'));$('indexMergeBtn').addEventListener('click',()=>opPost('/admin/api/index/merge','Index gemerged'));async function opPost(path,msg){try{await req(path,{method:'POST',body:'{}'});toast(msg);loadOps()}catch(e){toast(e.message,true)}}
async function boot(){if(!admin){await setToken();if(!admin)return}await refreshAll();setInterval(()=>{if(admin&&['dashboard','goals'].includes(currentView))refreshAll()},15000)}boot();
</script>
diff --git a/platform/neuroforge/internal/httpapi/status_dashboard_test.go b/platform/neuroforge/internal/httpapi/status_dashboard_test.go
new file mode 100644
index 0000000..0e15a3a
--- /dev/null
+++ b/platform/neuroforge/internal/httpapi/status_dashboard_test.go
@@ -0,0 +1,68 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "neuroforge/internal/core"
+)
+
+func TestAdminStatusKeepsFlatDashboardFields(t *testing.T) {
+ s, _ := newMetricsTestServer(t)
+ if err := s.store.AddMemory(&core.Memory{Text: "status dashboard regression", MemoryType: "semantic"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
+ req.Header.Set("X-Admin-Token", s.store.Secrets().AdminToken)
+ rr := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ stats, ok := body["stats"].(map[string]any)
+ if !ok {
+ t.Fatalf("missing nested stats: %#v", body["stats"])
+ }
+ for _, key := range []string{"revision", "memories", "synapses", "pending_jobs", "hnsw_nodes", "disk_pq_items", "index_mode", "maintenance"} {
+ flat, exists := body[key]
+ if !exists {
+ t.Fatalf("flat status field %q missing", key)
+ }
+ nested, exists := stats[key]
+ if !exists {
+ t.Fatalf("nested status field %q missing", key)
+ }
+ if key != "maintenance" && key != "index_mode" && flat != nested {
+ t.Fatalf("field %q flat=%v nested=%v", key, flat, nested)
+ }
+ }
+ if got := int(body["memories"].(float64)); got != 1 {
+ t.Fatalf("memories=%d want 1", got)
+ }
+}
+
+func TestAdminDashboardNormalizesNestedStatusResponse(t *testing.T) {
+ b, err := webFS.ReadFile("index.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ html := string(b)
+ for _, want := range []string{
+ "function normalizeStatus(s)",
+ "s=normalizeStatus(raw)",
+ "bereit · rev '+fmtN(s.revision)",
+ } {
+ if !strings.Contains(html, want) {
+ t.Fatalf("dashboard missing status compatibility guard %q", want)
+ }
+ }
+}

View File

@@ -460,12 +460,18 @@ func (s *Server) adminStatus(w http.ResponseWriter, r *http.Request) {
"last_index": obs.ClusterLastIndex, "commit_index": obs.ClusterCommitIndex, "peers": obs.ClusterPeers, "voters": obs.ClusterVoters, "quorum": obs.ClusterQuorum,
"replicated_log": obs.ClusterLog,
}
s.json(w, 200, map[string]any{
payload := map[string]any{
"stats": stats, "wal": s.store.WALStatus(),
"storage": map[string]any{"memory_segments": obs.Segments, "index_snapshot": map[string]any{"revision": obs.IndexSnapshotRevision, "deltas": obs.IndexDeltaCount, "segmented": cfg.Storage.IndexSegments.Enabled}, "tiering": tiering, "disk_ann": s.store.DiskANNStatus()},
"cluster": cluster, "cost": s.cost.Totals(), "providers": providers, "usage": s.store.RecentUsage(25),
"observability": obs, "runtime": currentRuntimeSnapshot(), "http": s.metrics.dashboardSnapshot(),
})
}
// Keep the original flat status fields for dashboard and external-client
// compatibility while retaining the richer nested stats object.
for k, v := range stats {
payload[k] = v
}
s.json(w, 200, payload)
}
func (s *Server) adminGetConfig(w http.ResponseWriter, r *http.Request) {
s.json(w, 200, s.store.Config())

View File

@@ -148,6 +148,7 @@ function toast(msg,bad=false){let t=$('toast');t.textContent=msg;t.className='to
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
function safeHref(raw){try{let u=new URL(String(raw||''),location.origin);return (u.protocol==='http:'||u.protocol==='https:')?u.href:'#'}catch{return '#'}}
function fmtN(n){return Number(n||0).toLocaleString('de-DE')}function fmtBytes(n){n=Number(n||0);if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KiB';if(n<1073741824)return(n/1048576).toFixed(1)+' MiB';return(n/1073741824).toFixed(2)+' GiB'}
function normalizeStatus(s){s=s&&typeof s==='object'?s:{};let st=s.stats&&typeof s.stats==='object'?s.stats:{};return {...s,...st}}
function when(d){if(!d)return'';let x=new Date(d),ms=x-Date.now(),a=Math.abs(ms),m=Math.round(a/60000);if(a<60000)return ms>0?'gleich':'gerade';if(m<60)return(ms>0?'in ':'vor ')+m+' min';let h=Math.round(m/60);return(ms>0?'in ':'vor ')+h+' h'}
function validHeaderToken(value){return typeof value==='string'&&/^[\x21-\x7E]+$/.test(value)}
function tokenHeaders(json=true){if(!admin)throw new Error('Admin-Token fehlt.');if(!validHeaderToken(admin))throw new Error('Admin-Token enthält Leerzeichen/Unicode. Bitte echten ASCII-Token neu eingeben.');let h={'X-Admin-Token':admin};if(json)h['Content-Type']='application/json';return h}
@@ -156,7 +157,7 @@ async function setToken(){let v=prompt('Admin-Token',admin||'');if(v===null)retu
function switchView(v){currentView=v;$$('.view').forEach(x=>x.classList.toggle('active',x.id==='view-'+v));$$('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view===v));$('pageTitle').textContent=viewMeta[v][0];$('pageSub').textContent=viewMeta[v][1];if(v!=='goals')stopResearchPolling();if(v==='knowledge')loadKnowledgeGraph();if(v==='sources')loadSources();if(v==='research')loadResearchConfig();if(v==='goals'){loadGoals();if(researchWatch.goalId)startResearchPolling()}if(v==='models')loadModels();if(v==='ops')loadOps()}
$$('[data-view]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view)));$('tokenBtn').addEventListener('click',setToken);$('refreshBtn').addEventListener('click',refreshAll);
async function refreshAll(){if(!admin){$('sideStatus').textContent='Admin-Token setzen';return}try{let [s,k,g,src]=await Promise.all([req('/admin/api/status'),req('/admin/api/knowledge/summary'),req('/api/v1/goals'),req('/api/v1/sources?limit=8')]);$('sideDot').className='dot ok';$('sideStatus').textContent='bereit · rev '+s.revision;$('mMem').textContent=fmtN(s.memories);$('mSyn').textContent=fmtN(s.synapses);$('mSources').textContent=fmtN(k.sources||src.length);let active=(g||[]).filter(x=>x.status==='active');$('mGoals').textContent=active.length;$('mNextGoal').textContent=active.length?('nächster '+when(active.map(x=>x.next_cycle_at).filter(Boolean).sort()[0])):'keine aktiven Ziele';$('mTypes').textContent=Object.entries(k.by_type||{}).map(([a,b])=>a+' '+b).join(' · ');renderSystemState(s);renderEvents(k.recent_events||[],'dashEvents');renderDashboardGoals(active.slice(0,5));if(currentView==='ops')loadOps()}catch(e){$('sideDot').className='dot bad';$('sideStatus').textContent='Fehler';toast(e.message,true)}}
async function refreshAll(){if(!admin){$('sideStatus').textContent='Admin-Token setzen';return}try{let [raw,k,g,src]=await Promise.all([req('/admin/api/status'),req('/admin/api/knowledge/summary'),req('/api/v1/goals'),req('/api/v1/sources?limit=8')]),s=normalizeStatus(raw);src=Array.isArray(src)?src:[];g=Array.isArray(g)?g:[];$('sideDot').className='dot ok';$('sideStatus').textContent='bereit · rev '+fmtN(s.revision);$('mMem').textContent=fmtN(s.memories);$('mSyn').textContent=fmtN(s.synapses);$('mSources').textContent=fmtN(k?.sources||src.length);let active=g.filter(x=>x.status==='active');$('mGoals').textContent=active.length;$('mNextGoal').textContent=active.length?('nächster '+when(active.map(x=>x.next_cycle_at).filter(Boolean).sort()[0])):'keine aktiven Ziele';$('mTypes').textContent=Object.entries(k?.by_type||{}).map(([a,b])=>a+' '+b).join(' · ');renderSystemState(s);renderEvents(k?.recent_events||[],'dashEvents');renderDashboardGoals(active.slice(0,5));if(currentView==='ops')loadOps()}catch(e){$('sideDot').className='dot bad';$('sideStatus').textContent='Fehler';toast(e.message,true)}}
function renderSystemState(s){let o=s.observability||{},m=s.maintenance||{};$('systemState').innerHTML=[['Index',`${s.index_mode||o.index_mode||''} · HNSW ${fmtN(s.hnsw_nodes)} · PQ ${fmtN(s.disk_pq_items)}`],['Jobs',fmtN(s.pending_jobs)+' ausstehend'],['Cluster',s.cluster?.enabled?(s.cluster.role+' · term '+s.cluster.term):'deaktiviert'],['Letzter Autonomie-Lauf',m.last_autonomy_run?new Date(m.last_autonomy_run).toLocaleString():''],['Letzter Fehler',m.last_error||'keiner']].map(([a,b])=>`<div class="item"><div class="itemTitle"><b>${esc(a)}</b><span class="muted small">${esc(b)}</span></div></div>`).join('')}
function renderEvents(xs,id){xs=Array.isArray(xs)?xs:[];$(id).innerHTML=xs.length?xs.map(e=>`<div class="item"><div class="itemTitle"><b>${esc(e.type)}</b><span class="pill">${esc(e.actor||'system')}</span></div><p>${esc(e.summary||'')}</p><div class="meta"><span>${new Date(e.created_at).toLocaleString()}</span>${e.reason?`<span>${esc(e.reason)}</span>`:''}</div></div>`).join(''):'<p class="muted">Noch keine Lernereignisse.</p>'}
function renderDashboardGoals(xs){$('dashGoals').innerHTML=xs.length?xs.map(g=>`<div class="item"><div class="itemTitle"><b>${esc(g.title)}</b><span class="pill ${g.auto_run?'good':''}">${g.auto_run?'AUTO':'MANUELL'}</span></div><div class="progress" style="margin:9px 0"><span style="width:${Math.round((g.progress||0)*100)}%"></span></div><div class="meta"><span>${Math.round((g.progress||0)*100)}%</span><span>${g.research_enabled?'Research an':'Research aus'}</span><span>${g.next_cycle_at?when(g.next_cycle_at):'nicht geplant'}</span></div></div>`).join(''):'<p class="muted">Keine aktiven Ziele.</p>'}
@@ -222,7 +223,7 @@ $('chatBtn').addEventListener('click',async()=>{let input=document.getElementByI
async function loadModels(){try{let d=await req('/admin/api/model-routing');$('modelJSON').value=JSON.stringify(d,null,2);let nodes=d.ollama||[];$('modelCards').innerHTML=nodes.map(n=>`<div class="item"><div class="itemTitle"><b>${esc(n.name||n.id)}</b><span class="pill ${n.enabled?'good':''}">${n.enabled?'aktiv':'aus'}</span></div><p class="mono small">${esc(n.base_url)}</p><div class="meta"><span>Chat: ${esc(n.chat_model)}</span><span>Embed: ${esc(n.embedding_model)}</span><span>ctx ${n.num_ctx||'default'}</span><span>think ${esc(n.think||'off')}</span></div></div>`).join('')}catch(e){toast(e.message,true)}}$('modelSaveBtn').addEventListener('click',async()=>{try{let d=JSON.parse($('modelJSON').value);await req('/admin/api/model-routing',{method:'PUT',body:JSON.stringify(d)});toast('Modelle & Routing gespeichert.');loadModels()}catch(e){toast(e.message,true)}});$('modelHealthBtn').addEventListener('click',async()=>{try{$('modelHealth').textContent='prüfe …';let d=await req('/admin/api/provider-health',{method:'POST',body:'{}'});$('modelHealth').textContent=JSON.stringify(d,null,2)}catch(e){$('modelHealth').textContent=e.message}});
// ---- Operations ----
async function loadOps(){try{let [s,st,cfg]=await Promise.all([req('/admin/api/status'),req('/admin/api/storage'),req('/admin/api/config')]);$('opsIndex').innerHTML=`<div class="item">HNSW <b>${fmtN(s.hnsw_nodes)}</b></div><div class="item">Disk-PQ <b>${fmtN(s.disk_pq_items)}</b></div><div class="item">Mode <b>${esc(s.index_mode||'')}</b></div>`;$('opsStorage').innerHTML=`<div class="item">Revision <b>${fmtN(s.revision)}</b></div><div class="item">Segmente <b>${fmtN(st.segments?.files||st.memory_segments?.files||0)}</b></div><div class="item">Disk <b>${fmtBytes(st.segments?.bytes||st.memory_segments?.bytes||0)}</b></div>`;$('opsObs').innerHTML=`<div class="item">Pending jobs <b>${fmtN(s.pending_jobs)}</b></div><div class="item">Remote shards <b>${fmtN(s.remote_shards)}</b></div><div class="item">Letzter Fehler <span class="small">${esc(s.maintenance?.last_error||'keiner')}</span></div>`;$('configJSON').value=JSON.stringify(cfg,null,2)}catch(e){toast(e.message,true)}}$('configSaveBtn').addEventListener('click',async()=>{try{let c=JSON.parse($('configJSON').value);await req('/admin/api/config',{method:'PUT',body:JSON.stringify(c)});toast('Konfiguration gespeichert.')}catch(e){toast(e.message,true)}});$('checkpointBtn').addEventListener('click',()=>opPost('/admin/api/checkpoint','Checkpoint erstellt'));$('compactBtn').addEventListener('click',()=>opPost('/admin/api/storage/compact','Segmente kompaktiert'));$('pqBuildBtn').addEventListener('click',()=>opPost('/admin/api/index/disk/rebuild','PQ-Index neu gebaut'));$('indexMergeBtn').addEventListener('click',()=>opPost('/admin/api/index/merge','Index gemerged'));async function opPost(path,msg){try{await req(path,{method:'POST',body:'{}'});toast(msg);loadOps()}catch(e){toast(e.message,true)}}
async function loadOps(){try{let [raw,st,cfg]=await Promise.all([req('/admin/api/status'),req('/admin/api/storage'),req('/admin/api/config')]),s=normalizeStatus(raw);$('opsIndex').innerHTML=`<div class="item">HNSW <b>${fmtN(s.hnsw_nodes)}</b></div><div class="item">Disk-PQ <b>${fmtN(s.disk_pq_items)}</b></div><div class="item">Mode <b>${esc(s.index_mode||'')}</b></div>`;$('opsStorage').innerHTML=`<div class="item">Revision <b>${fmtN(s.revision)}</b></div><div class="item">Segmente <b>${fmtN(st.segments?.files||st.memory_segments?.files||0)}</b></div><div class="item">Disk <b>${fmtBytes(st.segments?.bytes||st.memory_segments?.bytes||0)}</b></div>`;$('opsObs').innerHTML=`<div class="item">Pending jobs <b>${fmtN(s.pending_jobs)}</b></div><div class="item">Remote shards <b>${fmtN(s.remote_shards)}</b></div><div class="item">Letzter Fehler <span class="small">${esc(s.maintenance?.last_error||'keiner')}</span></div>`;$('configJSON').value=JSON.stringify(cfg,null,2)}catch(e){toast(e.message,true)}}$('configSaveBtn').addEventListener('click',async()=>{try{let c=JSON.parse($('configJSON').value);await req('/admin/api/config',{method:'PUT',body:JSON.stringify(c)});toast('Konfiguration gespeichert.')}catch(e){toast(e.message,true)}});$('checkpointBtn').addEventListener('click',()=>opPost('/admin/api/checkpoint','Checkpoint erstellt'));$('compactBtn').addEventListener('click',()=>opPost('/admin/api/storage/compact','Segmente kompaktiert'));$('pqBuildBtn').addEventListener('click',()=>opPost('/admin/api/index/disk/rebuild','PQ-Index neu gebaut'));$('indexMergeBtn').addEventListener('click',()=>opPost('/admin/api/index/merge','Index gemerged'));async function opPost(path,msg){try{await req(path,{method:'POST',body:'{}'});toast(msg);loadOps()}catch(e){toast(e.message,true)}}
async function boot(){if(!admin){await setToken();if(!admin)return}await refreshAll();setInterval(()=>{if(admin&&['dashboard','goals'].includes(currentView))refreshAll()},15000)}boot();
</script>

View File

@@ -0,0 +1,68 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"neuroforge/internal/core"
)
func TestAdminStatusKeepsFlatDashboardFields(t *testing.T) {
s, _ := newMetricsTestServer(t)
if err := s.store.AddMemory(&core.Memory{Text: "status dashboard regression", MemoryType: "semantic"}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.Header.Set("X-Admin-Token", s.store.Secrets().AdminToken)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
stats, ok := body["stats"].(map[string]any)
if !ok {
t.Fatalf("missing nested stats: %#v", body["stats"])
}
for _, key := range []string{"revision", "memories", "synapses", "pending_jobs", "hnsw_nodes", "disk_pq_items", "index_mode", "maintenance"} {
flat, exists := body[key]
if !exists {
t.Fatalf("flat status field %q missing", key)
}
nested, exists := stats[key]
if !exists {
t.Fatalf("nested status field %q missing", key)
}
if key != "maintenance" && key != "index_mode" && flat != nested {
t.Fatalf("field %q flat=%v nested=%v", key, flat, nested)
}
}
if got := int(body["memories"].(float64)); got != 1 {
t.Fatalf("memories=%d want 1", got)
}
}
func TestAdminDashboardNormalizesNestedStatusResponse(t *testing.T) {
b, err := webFS.ReadFile("index.html")
if err != nil {
t.Fatal(err)
}
html := string(b)
for _, want := range []string{
"function normalizeStatus(s)",
"s=normalizeStatus(raw)",
"bereit · rev '+fmtN(s.revision)",
} {
if !strings.Contains(html, want) {
t.Fatalf("dashboard missing status compatibility guard %q", want)
}
}
}