(() => {
'use strict';
const $ = id => document.getElementById(id);
const state = {payload: null, loading: false, refreshTimer: 0, live: null, openRun: '', openEvents: new Set()};
const nf = new Intl.NumberFormat('de-DE');
const dtf = new Intl.DateTimeFormat('de-DE', {dateStyle: 'short', timeStyle: 'medium'});
const tf = new Intl.DateTimeFormat('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
function esc(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
}
function num(value) { return nf.format(Number(value || 0)); }
function pct(value) { return `${Math.round(Number(value || 0) * 100)} %`; }
function when(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '–' : dtf.format(date);
}
function clock(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '–' : tf.format(date);
}
function duration(ms) {
ms = Number(ms || 0);
if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
if (minutes < 60) return `${minutes} min ${seconds} s`;
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
}
function bytes(value) {
let number = Number(value || 0);
const units = ['B','KiB','MiB','GiB','TiB'];
let index = 0;
while (number >= 1024 && index < units.length - 1) { number /= 1024; index++; }
return `${number.toFixed(index ? 1 : 0)} ${units[index]}`;
}
function statusClass(value) {
return ['success','warning','error','running'].includes(value) ? value : 'neutral';
}
function mutationTotals(m = {}) {
return {
created: Number(m.nodes_created || 0) + Number(m.edges_created || 0) + Number(m.vectors_created || 0),
updated: Number(m.nodes_updated || 0) + Number(m.edges_updated || 0) + Number(m.vectors_updated || 0),
deleted: Number(m.nodes_deleted || 0) + Number(m.edges_deleted || 0) + Number(m.vectors_deleted || 0)
};
}
function hasMutation(m = {}) {
const t = mutationTotals(m);
return t.created + t.updated + t.deleted > 0;
}
function deltaTags(m = {}) {
const tags = [];
if (Number(m.nodes_created)) tags.push(`+${num(m.nodes_created)} Nodes`);
if (Number(m.edges_created)) tags.push(`+${num(m.edges_created)} Edges`);
if (Number(m.vectors_created)) tags.push(`+${num(m.vectors_created)} Vektoren`);
if (Number(m.nodes_updated)) tags.push(`~${num(m.nodes_updated)} Nodes`);
if (Number(m.edges_updated)) tags.push(`~${num(m.edges_updated)} Edges`);
if (Number(m.vectors_updated)) tags.push(`↻${num(m.vectors_updated)} Vektoren`);
if (Number(m.nodes_deleted)) tags.push(`−${num(m.nodes_deleted)} Nodes`);
if (Number(m.edges_deleted)) tags.push(`−${num(m.edges_deleted)} Edges`);
if (Number(m.vectors_deleted)) tags.push(`−${num(m.vectors_deleted)} Vektoren`);
return tags.length ? tags.join('') : 'keine Graphänderung';
}
function metadataValue(metadata, ...keys) {
for (const key of keys) {
const value = metadata?.[key];
if (value !== undefined && value !== null && value !== '') return value;
}
return undefined;
}
function eventSeverity(activity = {}) {
const type = String(activity.type || '').toLowerCase();
const message = String(activity.message || '').toLowerCase();
if (type.includes('failed') || type.includes('error') || message.includes('fehlgeschlagen')) return 'error';
if (type.includes('skipped') || type.includes('rejected') || type.includes('no_candidate') || type.includes('paused') || type.includes('deferred') || message.includes('übersprungen')) return 'warning';
if (type.includes('completed') || type.includes('created') || type.includes('accepted') || type.includes('ingested') || type.includes('learned') || type.includes('synced') || type.includes('results') || type.includes('flushed')) return 'success';
return 'neutral';
}
async function api(path) {
const response = await fetch(path, {headers: {'Accept':'application/json'}, cache: 'no-store'});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function loadDashboard(reason = 'manual') {
if (state.loading) return;
state.loading = true;
const banner = $('connectionBanner');
banner.className = 'connection-banner loading';
banner.textContent = reason === 'live' ? 'Neue Aktivität erkannt · Analyse wird aktualisiert …' : 'Analysedaten werden geladen …';
const hours = Number($('rangeHours').value || 24);
$('exportAnalysis').href = `/api/analysis/export?hours=${hours}`;
try {
state.payload = await api(`/api/analysis/dashboard?hours=${hours}&limit=400`);
renderAll();
banner.className = 'connection-banner connected';
banner.textContent = `Verbunden · Analyse bis ${clock(state.payload.generated_at)} · ${num(state.payload.history?.raw_event_count)} Ereignisse im Zeitraum`;
} catch (error) {
banner.className = 'connection-banner error';
banner.textContent = `Analyse konnte nicht geladen werden: ${error.message}`;
} finally {
state.loading = false;
}
}
function renderAll() {
renderLastRun();
renderSummary();
renderTimeline();
renderRuns();
renderDistributions();
renderRelations();
renderEmbeddings();
renderResearch();
renderNewest();
renderChanges();
renderEvents();
$('generatedAt').textContent = `Erzeugt am ${when(state.payload.generated_at)} · Zeitraum ${state.payload.range_hours} h`;
}
function renderLastRun() {
const runs = state.payload?.history?.runs || [];
const run = runs.find(item => item.status !== 'running') || runs[0];
const status = $('lastRunStatus');
if (!run) {
status.className = 'status-pill neutral'; status.textContent = 'noch keine Daten';
$('lastRunContent').className = 'last-run-content empty-state';
$('lastRunContent').textContent = 'Es wurde noch kein auswertbarer Lauf aufgezeichnet.';
return;
}
status.className = `status-pill ${statusClass(run.status)}`;
status.textContent = run.verdict || run.status;
const totals = mutationTotals(run.mutations);
$('lastRunContent').className = 'last-run-content';
$('lastRunContent').innerHTML = `
${esc(run.kind)} · ${when(run.started_at)}${run.trigger ? ` · Trigger: ${esc(run.trigger)}` : ''}${esc(run.title)}
${esc(run.explanation || run.outcome || 'Keine zusätzliche Erklärung vorhanden.')}
${deltaTags(run.mutations)}
Neu${num(totals.created)}Nodes, Edges, Vektoren
Aktualisiert${num(totals.updated)}inkl. neu berechneter Vektoren
Dauer${duration(run.duration_ms)}${num(run.event_count)} technische Ereignisse
`;
}
function renderSummary() {
const graph = state.payload.graph || {};
const summary = graph.summary || {};
const history = state.payload.history || {};
const system = state.payload.system || {};
const totals = mutationTotals(history.totals || {});
$('summaryGraph').textContent = `${num(summary.node_count)} / ${num(summary.edge_count)}`;
$('summaryGraphSub').textContent = `${num(summary.node_count)} Nodes · ${num(summary.edge_count)} aktive Edges · Version ${num(system.version)}`;
$('summaryCoverage').textContent = pct(graph.vector_coverage);
$('summaryCoverageSub').textContent = `${num(graph.vector_rows)} Embeddings für ${num(graph.vector_eligible_nodes)} wissenshaltige Nodes`;
$('summaryChanges').textContent = `+${num(totals.created)} · ~${num(totals.updated)} · −${num(totals.deleted)}`;
$('summaryChangesSub').textContent = `Neu · aktualisiert/neu berechnet · entfernt in ${state.payload.range_hours} h`;
$('summaryQuality').textContent = `${num(summary.components)} / ${num(summary.knowledge_orphans)} / ${num(summary.contradictions)}`;
$('summaryQualitySub').textContent = 'Komponenten · Wissenswaisen · Widerspruchs-Edges';
const settings = system.runtime_settings || {};
$('summaryThinking').textContent = settings.thinking_enabled ? (system.enrich_running ? 'LÄUFT' : 'AKTIV') : 'PAUSIERT';
$('summaryThinkingSub').textContent = `${num(system.relations_created)} Relationen · ${num(system.articles_created)} Artikel · ${(settings.processing_mode === 'clustered' ? 'CLUSTER/FAST' : 'PRÄZISE')}`;
const autonomous = system.autonomous_research || {};
const queue = autonomous.counts || {};
$('summaryAutonomous').textContent = settings.autonomous_research_enabled ? (autonomous.running ? 'LÄUFT' : 'AKTIV') : 'PAUSIERT';
$('summaryAutonomousSub').textContent = `${num(queue.queued)} wartend · ${num(queue.completed)} abgeschlossen · ${num(queue.failed)} fehlgeschlagen`;
const pool = system.ollama_pool || {};
const nodes = pool.nodes || [];
const healthy = nodes.filter(node => node.healthy && node.compatible).length;
$('summaryOllama').textContent = `${healthy}/${nodes.length || 0} gesund`;
const shared = pool.shared_queue || {};
const dedupe = system.research_dedupe || {};
const articleModels = system.article_model_status || {};
const author = articleModels.synthesis || {};
const reviewer = articleModels.review || {};
const modelText = author.model || reviewer.model ? ` · Artikel ${esc(author.model || '?')}→${esc(reviewer.model || '?')} (${num(author.healthy_nodes_with_model)}/${num(reviewer.healthy_nodes_with_model)} Nodes)` : '';
const workKinds = shared.kinds || {};
const kindCount = prefix => {
let active = 0, waiting = 0;
Object.entries(workKinds).forEach(([name, item]) => {
if (name === prefix || name.startsWith(prefix + '.')) { active += Number(item?.active || 0); waiting += Number(item?.waiting || 0); }
});
return `${num(active)}/${num(waiting)}`;
};
const queueKindsText = Object.keys(workKinds).length ? ` · Search ${kindCount('searxng')} · Fetch ${kindCount('web.fetch')} · Chat ${kindCount('ollama.chat')} · Embed ${kindCount('ollama.embedding')}` : '';
$('summaryOllamaSub').innerHTML = `${num(shared.active)} aktiv · ${num(shared.waiting)} gemeinsam wartend${queueKindsText} · Dedupe ${num(dedupe.inflight)} laufend/${num(dedupe.cached)} Cache${modelText}`;
const storage = system.graph_storage || {};
const persistence = system.persistence || {};
const audit = state.payload.history?.audit || {};
const persistenceError = persistence.last_error || audit.last_error;
$('summaryPersistence').textContent = persistenceError ? 'FEHLER' : (persistence.graph_dirty ? 'OFFEN' : 'SAUBER');
$('summaryPersistenceSub').textContent = persistenceError ? `Audit/SQLite: ${persistenceError}` : `${bytes(storage.database_bytes)} DB · ${bytes(storage.wal_bytes)} WAL · ${num(Number(storage.pending_nodes||0)+Number(storage.pending_edges||0)+Number(storage.pending_vectors||0))} offene Upserts · Audit ${num(audit.queue_depth)}/${num(audit.queue_capacity)}`;
}
function renderTimeline() {
const canvas = $('timelineCanvas');
const data = state.payload?.history?.timeline || [];
$('timelineHint').textContent = `${data.length} Zeitsegmente · ${state.payload.range_hours} Stunden`;
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(600, Math.floor(rect.width * dpr));
canvas.height = Math.floor(300 * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr,0,0,dpr,0,0);
const width = canvas.width / dpr, height = canvas.height / dpr;
ctx.clearRect(0,0,width,height);
ctx.fillStyle = '#07131d'; ctx.fillRect(0,0,width,height);
if (!data.length) { ctx.fillStyle='#8ca4b7'; ctx.font='13px Segoe UI'; ctx.fillText('Noch keine historischen Daten für diesen Zeitraum.',20,35); return; }
const pad = {l:48,r:22,t:22,b:38};
const chartW = width-pad.l-pad.r, chartH = height-pad.t-pad.b, mid = pad.t+chartH*.63;
const values = data.map(bucket => {
const m=bucket.mutations||{};
return {create:Number(m.nodes_created||0)+Number(m.edges_created||0)+Number(m.vectors_created||0),update:Number(m.nodes_updated||0)+Number(m.edges_updated||0)+Number(m.vectors_updated||0),del:Number(m.nodes_deleted||0)+Number(m.edges_deleted||0)+Number(m.vectors_deleted||0),events:Number(bucket.events||0)};
});
const maxUp=Math.max(1,...values.map(v=>v.create+v.update));
const maxDown=Math.max(1,...values.map(v=>v.del));
const maxEvents=Math.max(1,...values.map(v=>v.events));
ctx.strokeStyle='#29404f';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(pad.l,mid+.5);ctx.lineTo(width-pad.r,mid+.5);ctx.stroke();
ctx.font='10px Segoe UI';ctx.fillStyle='#7890a3';ctx.textAlign='right';
ctx.fillText(num(maxUp),pad.l-8,pad.t+8);ctx.fillText('0',pad.l-8,mid+3);ctx.fillText(`−${num(maxDown)}`,pad.l-8,height-pad.b);
const slot=chartW/data.length, bar=Math.max(2,Math.min(18,slot*.58));
const eventPoints=[];
data.forEach((bucket,index)=>{
const x=pad.l+slot*index+slot/2; const v=values[index];
const createH=(v.create/maxUp)*(mid-pad.t-8); const updateH=(v.update/maxUp)*(mid-pad.t-8);
ctx.fillStyle='#62d39a';ctx.fillRect(x-bar/2,mid-createH,bar,createH);
ctx.fillStyle='#5e8cff';ctx.fillRect(x-bar/2,mid-createH-updateH,bar,updateH);
const delH=(v.del/maxDown)*(height-pad.b-mid-5);ctx.fillStyle='#ff7d84';ctx.fillRect(x-bar/2,mid+1,bar,delH);
const ey=pad.t+(1-v.events/maxEvents)*(mid-pad.t-10);eventPoints.push([x,ey]);
if(index===0||index===data.length-1||index%Math.max(1,Math.ceil(data.length/6))===0){ctx.fillStyle='#7890a3';ctx.textAlign='center';ctx.fillText(new Date(bucket.start).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),x,height-16)}
});
ctx.strokeStyle='#f2c86d';ctx.lineWidth=1.7;ctx.beginPath();eventPoints.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));ctx.stroke();
ctx.fillStyle='#f2c86d';eventPoints.forEach(([x,y])=>{ctx.beginPath();ctx.arc(x,y,2.2,0,Math.PI*2);ctx.fill()});
}
function renderRuns() {
const runs = state.payload?.history?.runs || [];
const kindFilter = $('runKindFilter');
const existing = new Set([...kindFilter.options].map(option => option.value));
[...new Set(runs.map(run => run.kind).filter(Boolean))].sort().forEach(kind => { if (!existing.has(kind)) kindFilter.add(new Option(kind,kind)); });
const kind = kindFilter.value, status = $('runStatusFilter').value, search = $('runSearch').value.trim().toLowerCase();
const filtered = runs.filter(run => (!kind||run.kind===kind)&&(!status||run.status===status)&&(!search||JSON.stringify(run).toLowerCase().includes(search)));
$('runCount').textContent = `${num(filtered.length)} von ${num(runs.length)} Läufen`;
const body = $('runsBody');
if (!filtered.length) { body.innerHTML='| Keine Läufe entsprechen den Filtern. |
'; return; }
body.innerHTML = filtered.map(run => {
const metrics = run.metrics || {};
const metricText = [metrics.exact_comparisons ? `${num(metrics.exact_comparisons)} Cosine` : (metrics.comparisons ? `${num(metrics.comparisons)} Vergleiche` : ''), metrics.coarse_comparisons ? `${num(metrics.coarse_comparisons)} Hash` : '', metrics.research_search_results ? `${num(metrics.research_search_results)} Treffer` : '', metrics.research_accepted ? `${num(metrics.research_accepted)} Belege` : '', metrics.articles_created ? `${num(metrics.articles_created)} Artikel` : ''].filter(Boolean).join(' · ') || `${num(run.event_count)} Events`;
const open = state.openRun === run.id;
return `| ${clock(run.started_at)}${new Date(run.started_at).toLocaleDateString('de-DE')} | ${esc(run.title)}${esc(run.kind)}${run.trigger?` · ${esc(run.trigger)}`:''} | ${esc(run.verdict||run.status)}${esc(run.outcome||'')} | ${deltaTags(run.mutations)} | ${esc(metricText)} | ${run.status==='running'?'läuft':duration(run.duration_ms)} |
${open ? runDetails(run) : ''}`;
}).join('');
body.querySelectorAll('.run-row').forEach(row => row.addEventListener('click', () => { state.openRun = state.openRun === row.dataset.runId ? '' : row.dataset.runId; renderRuns(); }));
}
function runDetails(run) {
const events=(run.events||[]).slice().reverse();
return `Erklärung${esc(run.explanation||'Keine Erklärung vorhanden.')} ${deltaTags(run.mutations)} Messwerte${esc(JSON.stringify(run.metrics||{},null,2))}Betroffene IDs${esc(JSON.stringify({node_ids:run.node_ids||[],edge_ids:run.edge_ids||[]},null,2))}Ereignisse dieses Laufs${events.map(event=>` ${clock(event.activity.timestamp)} · ${esc(event.activity.type)}${esc(event.activity.message||event.activity.phase||'')}${deltaTags(event.point?.delta)} ${event.change_count?` ${num(event.change_count)} konkrete Änderungen${event.changes_truncated?` · ${num(event.changes_truncated)} gekürzt`:''}`:''} `).join('')} |
`;
}
function renderBarList(id, values, limit = 12) {
const entries = Object.entries(values || {}).sort((a,b)=>b[1]-a[1]).slice(0,limit);
const max = Math.max(1,...entries.map(entry=>Number(entry[1])));
$(id).innerHTML = entries.length ? entries.map(([label,value])=>`${esc(label)}${num(value)}
`).join('') : 'Keine Daten';
}
function renderDistributions() {
const graph=state.payload.graph||{};
renderBarList('sourceDistribution',graph.node_sources,14);
renderBarList('kindDistribution',graph.node_kinds,14);
renderBarList('originDistribution',graph.node_origins,14);
renderBarList('edgeTypeDistribution',graph.edge_types,14);
}
function renderRelations() {
const graph=state.payload.graph||{}, summary=graph.summary||{};
$('relationAIEdges').textContent=num(summary.ai_edges);
$('relationConfidence').textContent=graph.average_ai_confidence ? pct(graph.average_ai_confidence) : '–';
$('relationSimilarity').textContent=graph.average_ai_similarity ? pct(graph.average_ai_similarity) : '–';
$('relationContradictions').textContent=num(summary.contradictions);
const observations=(state.payload.history?.events||[]).filter(event=>metadataValue(event.activity?.metadata,'semantic_similarity')!==undefined).slice(0,12);
$('similarityList').innerHTML=observations.length?observations.map(event=>{const m=event.activity.metadata||{};return `${esc(event.activity.message||event.activity.type)}${pct(Number(m.semantic_similarity))}${when(event.activity.timestamp)} · ${num(m.candidate_comparisons||m.comparisons||0)} Vergleiche${m.confidence!==undefined?` · ${pct(m.confidence)} Konfidenz`:''}
`}).join(''):'Noch keine Näheprüfungen im Zeitraum.';
}
function renderEmbeddings() {
const graph=state.payload.graph||{}, totals=state.payload.history?.totals||{};
$('embeddingRows').textContent=num(graph.vector_rows);
$('embeddingCreated').textContent=num(totals.vectors_created);
$('embeddingUpdated').textContent=num(totals.vectors_updated);
$('embeddingDeleted').textContent=num(totals.vectors_deleted);
$('embeddingCoverageBadge').className=`status-pill ${graph.vector_coverage>=.95?'success':graph.vector_coverage>=.7?'warning':'error'}`;
$('embeddingCoverageBadge').textContent=pct(graph.vector_coverage);
renderBarList('embeddingDimensions',graph.embedding_dimensions,8);
const events=(state.payload.history?.events||[]).filter(event=>event.activity?.type?.includes('embedding')||event.activity?.type?.includes('.learned')).slice(0,12);
$('embeddingEvents').innerHTML=events.length?events.map(event=>`${esc(event.activity.message||event.activity.type)}${clock(event.activity.timestamp)}${esc(event.activity.type)} · ${esc(metadataValue(event.activity.metadata,'model')||'')} ${metadataValue(event.activity.metadata,'dimensions')?`· ${num(metadataValue(event.activity.metadata,'dimensions'))} Dimensionen`:''}${deltaTags(event.point?.delta)}
${event.change_count?`
${num(event.change_count)} konkrete Änderungen${event.changes_truncated?` · ${num(event.changes_truncated)} gekürzt`:''}`:''}
`).join(''):'Keine Embedding-Ereignisse im Zeitraum.';
}
function researchMetrics() {
const events=state.payload.history?.events||[];
const metrics={results:0,fetched:0,accepted:0,rejected:0,articles:0,failures:0};
events.forEach(event=>{const a=event.activity||{},m=a.metadata||{},type=a.type||'';if(type.includes('research.results'))metrics.results+=Number(m.result_count||m.research_search_results||0);if(type.includes('fetch.completed'))metrics.fetched++;if(type.includes('evidence.accepted'))metrics.accepted++;if(type.includes('evidence.rejected'))metrics.rejected++;if(type==='article.created'||(type==='autonomous.research.task.completed'&&m.article_created))metrics.articles++;if(type.includes('research')&&type.includes('failed'))metrics.failures++;});
return metrics;
}
function renderResearch() {
const metrics=researchMetrics();
$('researchResults').textContent=num(metrics.results);$('researchFetched').textContent=num(metrics.fetched);$('researchAccepted').textContent=num(metrics.accepted);$('researchArticles').textContent=num(metrics.articles);
$('researchBadge').className=`status-pill ${metrics.failures?'warning':metrics.accepted||metrics.results?'success':'neutral'}`;
$('researchBadge').textContent=metrics.failures?`${num(metrics.failures)} Fehler`:metrics.accepted?`${num(metrics.accepted)} Belege`:'keine Aktivität';
const runs=(state.payload.history?.runs||[]).filter(run=>['research','autonomous-research','searxng-test','opportunity-scan'].includes(run.kind)).slice(0,12);
$('researchRuns').innerHTML=runs.length?runs.map(run=>`${esc(run.title)}${esc(run.verdict||run.status)}${when(run.started_at)} · ${duration(run.duration_ms)} · ${num(run.event_count)} Events${esc(run.explanation||run.outcome||'')}
${deltaTags(run.mutations)}
`).join(''):'Keine Rechercheläufe im Zeitraum.';
}
function renderNewest() {
const graph=state.payload.graph||{};
$('newestNodes').innerHTML=(graph.newest_nodes||[]).map(node=>`${esc(node.label)}${when(node.updated_at)}${esc(node.summary||'Keine Zusammenfassung')}
${esc(node.kind)}${esc(node.status||'kein Status')}${esc(node.origin)}${node.metadata?.source?`source: ${esc(node.metadata.source)}`:''}
${esc(node.id)} `).join('')||'Keine Nodes vorhanden.';
$('newestEdges').innerHTML=(graph.newest_edges||[]).map(edge=>`${esc(edge.type)}${when(edge.updated_at)}${esc(edge.explanation||'Keine Erklärung')} ${edge.confidence?`· Konfidenz ${pct(edge.confidence)}`:''}
${esc(edge.status||'kein Status')}${esc(edge.origin)}
${esc(edge.source)} → ${esc(edge.target)} `).join('')||'Keine Edges vorhanden.';
}
function changeActionLabel(action) {
return ({created:'erzeugt',updated:'aktualisiert',recalculated:'neu berechnet',deleted:'entfernt'})[action] || action || 'unbekannt';
}
function renderChanges() {
const history = state.payload?.history || {};
const changes = history.changes || [];
const kind = $('changeKindFilter').value;
const action = $('changeActionFilter').value;
const search = $('changeSearch').value.trim().toLowerCase();
const filtered = changes.filter(change => (!kind || change.entity_kind === kind) && (!action || change.action === action) && (!search || JSON.stringify(change).toLowerCase().includes(search)));
$('changeCount').textContent = `${num(filtered.length)} angezeigt · ${num(history.change_count)} insgesamt im Zeitraum`;
const dropped = Number(history.dropped_detailed_changes || 0);
const truncatedEvents = (history.events || []).reduce((sum, event) => sum + Number(event.changes_truncated || 0), 0);
const notice = $('changeTruncationNotice');
if (dropped || truncatedEvents || Number(history.change_count || 0) > changes.length) {
notice.hidden = false;
notice.textContent = `Es werden die neuesten ${num(changes.length)} Detailänderungen geladen. ${num(dropped || truncatedEvents)} weitere Detailzeilen wurden wegen des Schutzlimits nicht gespeichert; die Summen für Nodes, Edges und Vektoren bleiben vollständig.`;
} else {
notice.hidden = true;
}
const body = $('changesBody');
if (!filtered.length) {
body.innerHTML = '| Keine konkreten Änderungen entsprechen den Filtern. |
';
return;
}
body.innerHTML = filtered.map(change => {
const details = change.details || {};
const relation = change.relation_type || details.kind || '';
const endpoints = details.source && details.target ? `${details.source} → ${details.target}` : '';
const subtitle = change.label || endpoints || details.source || '';
const sourceText = details.source && change.entity_kind === 'node' ? `source: ${details.source}` : '';
const deltas = [];
if (details.semantic_similarity !== undefined || details.previous_semantic_similarity !== undefined) deltas.push(`Nähe ${details.previous_semantic_similarity!==undefined?pct(details.previous_semantic_similarity):'–'} → ${details.semantic_similarity!==undefined?pct(details.semantic_similarity):'–'}`);
if (details.confidence !== undefined && details.previous_confidence !== undefined) deltas.push(`Konfidenz ${pct(details.previous_confidence)} → ${pct(details.confidence)}`);
if (details.dimensions !== undefined) deltas.push(details.previous_dimensions!==undefined?`Dimensionen ${num(details.previous_dimensions)} → ${num(details.dimensions)}`:`${num(details.dimensions)} Dimensionen`);
const technical = [change.origin, sourceText, ...deltas].filter(Boolean).join(' · ');
return `| ${when(change.timestamp)} | ${esc(changeActionLabel(change.action))} | ${esc(change.entity_kind)}${details.status?`${esc(details.status)}`:''} | ${esc(change.entity_id)}${subtitle?`${esc(subtitle)}`:''} | ${esc(relation||'–')}${esc(technical)} | ${num(change.graph_version)} | |
`;
}).join('');
body.querySelectorAll('.event-link').forEach(button => button.addEventListener('click', event => {
event.stopPropagation();
$('eventSearch').value = button.dataset.eventId;
renderEvents();
document.querySelector('.raw-events-section')?.scrollIntoView({behavior:'smooth',block:'start'});
}));
}
function renderEvents() {
const events=state.payload.history?.events||[];
const typeFilter=$('eventTypeFilter'); const known=new Set([...typeFilter.options].map(option=>option.value));
[...new Set(events.map(event=>event.activity?.type).filter(Boolean))].sort().forEach(type=>{if(!known.has(type))typeFilter.add(new Option(type,type))});
const type=typeFilter.value,search=$('eventSearch').value.trim().toLowerCase(),changesOnly=$('changesOnly').checked;
const filtered=events.filter(event=>(!type||event.activity.type===type)&&(!changesOnly||hasMutation(event.point?.delta))&&(!search||JSON.stringify(event).toLowerCase().includes(search)));
$('eventCount').textContent=`${num(filtered.length)} von ${num(events.length)} Ereignissen`;
$('eventsList').innerHTML=filtered.length?filtered.map(event=>{const a=event.activity||{},open=state.openEvents.has(a.id);return `${when(a.timestamp)} ${esc(a.type)}${esc(a.message||a.query||a.phase||'')}${deltaTags(event.point?.delta)}
Aktivität${a.query?`
${esc(a.query)}
`:''}
${esc(JSON.stringify(a,null,2))} Graph-Checkpoint unmittelbar nach dem Event${esc(JSON.stringify({...event.point,change_count:event.change_count,changes_truncated:event.changes_truncated||0},null,2))} `}).join(''):'Keine Ereignisse entsprechen den Filtern.
';
$('eventsList').querySelectorAll('.event-card').forEach(card=>card.querySelector('.event-summary').addEventListener('click',()=>{const id=card.dataset.eventId;state.openEvents.has(id)?state.openEvents.delete(id):state.openEvents.add(id);renderEvents()}));
}
function scheduleLiveRefresh() {
if (!$('autoRefresh').checked) return;
clearTimeout(state.refreshTimer);
state.refreshTimer=setTimeout(()=>loadDashboard('live'),900);
}
function connectLive() {
if (state.live) state.live.close();
const source=new EventSource('/api/stream'); state.live=source;
source.addEventListener('activity',scheduleLiveRefresh);
source.onopen=()=>{if(!state.loading){$('connectionBanner').className='connection-banner live';$('connectionBanner').textContent='Live-Eventstream verbunden · wartet auf neue Hirnaktivität';}};
source.onerror=()=>{if(!state.loading){$('connectionBanner').className='connection-banner error';$('connectionBanner').textContent='Live-Eventstream unterbrochen · Dashboard kann weiterhin manuell aktualisiert werden';}};
}
$('refreshNow').addEventListener('click',()=>loadDashboard('manual'));
$('rangeHours').addEventListener('change',()=>loadDashboard('range'));
$('runKindFilter').addEventListener('change',renderRuns);$('runStatusFilter').addEventListener('change',renderRuns);$('runSearch').addEventListener('input',renderRuns);
$('changeKindFilter').addEventListener('change',renderChanges);$('changeActionFilter').addEventListener('change',renderChanges);$('changeSearch').addEventListener('input',renderChanges);
$('eventTypeFilter').addEventListener('change',renderEvents);$('eventSearch').addEventListener('input',renderEvents);$('changesOnly').addEventListener('change',renderEvents);
$('autoRefresh').addEventListener('change',event=>{event.target.checked?connectLive():state.live?.close()});
window.addEventListener('resize',()=>{if(state.payload)renderTimeline()});
loadDashboard('startup');
connectLive();
})();