Files
jbergner 440423c5b6
All checks were successful
release-tag / release-image (push) Successful in 2m43s
RC-3
2026-08-09 11:29:13 +02:00

530 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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 => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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(`<span class="delta-tag create">+${num(m.nodes_created)} Nodes</span>`);
if (Number(m.edges_created)) tags.push(`<span class="delta-tag create">+${num(m.edges_created)} Edges</span>`);
if (Number(m.vectors_created)) tags.push(`<span class="delta-tag create">+${num(m.vectors_created)} Vektoren</span>`);
if (Number(m.nodes_updated)) tags.push(`<span class="delta-tag update">~${num(m.nodes_updated)} Nodes</span>`);
if (Number(m.edges_updated)) tags.push(`<span class="delta-tag update">~${num(m.edges_updated)} Edges</span>`);
if (Number(m.vectors_updated)) tags.push(`<span class="delta-tag update">↻${num(m.vectors_updated)} Vektoren</span>`);
if (Number(m.nodes_deleted)) tags.push(`<span class="delta-tag delete">${num(m.nodes_deleted)} Nodes</span>`);
if (Number(m.edges_deleted)) tags.push(`<span class="delta-tag delete">${num(m.edges_deleted)} Edges</span>`);
if (Number(m.vectors_deleted)) tags.push(`<span class="delta-tag delete">${num(m.vectors_deleted)} Vektoren</span>`);
return tags.length ? tags.join('') : '<span class="delta-tag">keine Graphänderung</span>';
}
function runDeltaTags(run = {}) {
if (!run.mutations_known) return '<span class="delta-tag">nicht kausal gemessen</span>';
return deltaTags(run.mutations || {});
}
function statDeltaTags(item = {}) {
const samples = Number(item.mutation_samples || 0);
if (!samples) return '<span class="delta-tag">nicht kausal gemessen</span>';
return `${deltaTags(item.mutations || {})}<span class="run-meta">${num(samples)}/${num(item.runs)} Läufe kausal gemessen</span>`;
}
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}&limit=1000`;
try {
state.payload = await api(`/api/analysis/dashboard?hours=${hours}&limit=400`);
renderAll();
banner.className = 'connection-banner connected';
const selection = state.payload.history?.event_selection || {};
banner.textContent = `Verbunden · Analyse bis ${clock(state.payload.generated_at)} · ${num(selection.persisted_events ?? state.payload.history?.raw_event_count)} persistiert · ${num(selection.returned_events ?? state.payload.history?.events?.length)} aussagekräftige Events geladen`;
} catch (error) {
banner.className = 'connection-banner error';
banner.textContent = `Analyse konnte nicht geladen werden: ${error.message}`;
} finally {
state.loading = false;
}
}
function renderAll() {
renderLastRun();
renderSummary();
renderObservability();
renderReadiness();
renderPipelineSummaries();
renderRunStats();
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 = `
<div class="last-run-main"><small>${esc(run.kind)} · ${when(run.started_at)}${run.trigger ? ` · Trigger: ${esc(run.trigger)}` : ''}</small><h3>${esc(run.title)}</h3><p>${esc(run.explanation || run.outcome || 'Keine zusätzliche Erklärung vorhanden.')}</p><div class="delta-tags" style="margin-top:10px">${runDeltaTags(run)}</div></div>
<div class="last-run-stat"><span>Neu</span><strong>${num(totals.created)}</strong><small>Nodes, Edges, Vektoren</small></div>
<div class="last-run-stat"><span>Aktualisiert</span><strong>${num(totals.updated)}</strong><small>inkl. neu berechneter Vektoren</small></div>
<div class="last-run-stat"><span>Dauer</span><strong>${duration(run.duration_ms)}</strong><small>${num(run.event_count)} technische Ereignisse</small></div>`;
}
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 renderObservability() {
const history = state.payload?.history || {};
const selection = history.event_selection || {};
const audit = history.audit || {};
$('obsPersistedEvents').textContent = num(selection.persisted_events ?? history.raw_event_count);
$('obsReturnedEvents').textContent = num(selection.returned_events ?? (history.events || []).length);
$('obsCompactedScans').textContent = num(selection.unchanged_scan_runs);
$('obsCompactedEmbeddings').textContent = num(selection.embedding_batch_events_compacted);
$('obsAvoidedEvents').textContent = num(selection.avoided_persisted_events);
const badge = $('analysisCoverageBadge');
const droppedEvents = Number(history.dropped_events || 0);
const truncatedDetails = Number(history.detailed_changes_truncated ?? history.dropped_detailed_changes ?? 0);
const omitted = Number(selection.display_limit_omitted || 0);
badge.className = `status-pill ${droppedEvents ? 'error' : (omitted || truncatedDetails) ? 'warning' : 'success'}`;
badge.textContent = droppedEvents ? `${num(droppedEvents)} Events verloren` : omitted ? `${num(omitted)} nicht angezeigt` : truncatedDetails ? 'Details gekürzt' : 'vollständig';
const legacy = Number(selection.legacy_scan_runs_compacted || 0);
const persistedAggregates = Number(selection.persisted_scan_aggregates || 0);
const oldest = selection.oldest_returned_at ? when(selection.oldest_returned_at) : '';
const parts = [
`${num(selection.meaningful_events)} persistierte fachlich/technisch aussagekräftige Events im Zeitraum`,
`${num(selection.unchanged_scan_runs)} unveränderte KB-Scans und ${num(selection.embedding_batch_events_compacted)} Embedding-Batches werden als kompakte Zusammenfassungen gezeigt`,
legacy ? `${num(legacy)} ältere Scan-Läufe werden nur beim Lesen verdichtet; die SQLite-Rohdaten bleiben erhalten` : '',
persistedAggregates ? `${num(persistedAggregates)} neue Scan-Aggregate sind bereits platzsparend persistiert` : '',
`ältestes aktuell geladenes aussagekräftiges Event: ${oldest}`,
truncatedDetails ? `${num(truncatedDetails)} Detailänderungen wurden bei Massenmutationen gekürzt; die Summenzähler bleiben vollständig` : '',
audit.last_error ? `Audit-Fehler: ${audit.last_error}` : ''
].filter(Boolean);
$('analysisCoverageNote').innerHTML = `<b>Event-Hygiene:</b> ${parts.map(esc).join(' · ')}`;
}
function renderReadiness() {
const readiness = state.payload?.readiness || {};
const status = String(readiness.status || 'neutral');
const badge = $('readinessBadge');
const css = status === 'pass' ? 'success' : status === 'fail' ? 'error' : status === 'warning' ? 'warning' : 'neutral';
const label = status === 'pass' ? 'produktionsbereit' : status === 'fail' ? 'Blocker erkannt' : status === 'warning' ? 'Prüfung nötig' : 'unbekannt';
badge.className = `status-pill ${css}`;
badge.textContent = label;
$('readinessPassed').textContent = num(readiness.passed);
$('readinessWarnings').textContent = num(readiness.warnings);
$('readinessFailed').textContent = num(readiness.failed);
$('readinessStatus').textContent = label;
const checks = Array.isArray(readiness.checks) ? readiness.checks.slice() : [];
const readinessRank = {fail: 0, warning: 1, pass: 2};
checks.sort((a, b) => (readinessRank[a?.status] ?? 3) - (readinessRank[b?.status] ?? 3) || String(a?.label || a?.id || '').localeCompare(String(b?.label || b?.id || ''), 'de'));
badge.textContent = checks.length ? `${num(checks.length)} Checks · ${label}` : label;
const container = $('readinessChecks');
container.innerHTML = '';
if (!checks.length) {
container.innerHTML = '<span class="empty-state">Noch keine Plausibilitätschecks verfügbar.</span>';
return;
}
for (const check of checks) {
const stateClass = check?.status === 'pass' ? 'success' : check?.status === 'fail' ? 'error' : 'warning';
const stateLabel = check?.status === 'pass' ? 'OK' : check?.status === 'fail' ? 'BLOCKER' : 'PRÜFEN';
const item = document.createElement('div');
item.className = `readiness-item ${stateClass}`;
const details = check?.details && Object.keys(check.details).length
? `<small>${esc(Object.entries(check.details).map(([k,v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · '))}</small>`
: '';
item.innerHTML = `<header><b>${esc(check?.label || check?.id)}</b><span class="status-pill ${stateClass}">${stateLabel}</span></header><p>${esc(check?.message || '')}</p>${details}`;
container.appendChild(item);
}
}
function compactRunCards(kind, limit = 8) {
const runs = (state.payload?.history?.runs || []).filter(run => run.kind === kind).slice(0, limit);
return runs.length ? runs.map(run => `<div class="compact-item"><header><b>${esc(run.title)}</b><span class="status-pill ${statusClass(run.status)}">${esc(run.verdict || run.status)}</span></header><small>${when(run.started_at)} · ${run.status === 'running' ? 'läuft' : duration(run.duration_ms)} · ${num(run.event_count)} Events</small><p>${esc(run.explanation || run.outcome || '')}</p><div class="delta-tags">${runDeltaTags(run)}</div></div>`).join('') : '<span class="empty-state">Keine Läufe im Zeitraum.</span>';
}
function renderPipelineSummaries() {
const pipelines = state.payload?.history?.pipelines || {};
const security = pipelines.security || {};
$('securityMaterialized').textContent = num(security.materialized);
$('securityRejected').textContent = num(security.rejected);
$('securityFailed').textContent = num(security.failed);
$('securityConfidence').textContent = Number(security.confidence_samples || 0) ? pct(security.average_confidence) : '';
const securityBadge = $('securityPipelineBadge');
securityBadge.className = `status-pill ${Number(security.failed || 0) ? 'error' : Number(security.materialized || 0) ? 'success' : 'neutral'}`;
securityBadge.textContent = Number(security.failed || 0) ? `${num(security.failed)} Fehler` : `${num(security.materialized)} Nodes`;
renderBarList('securitySeverityDistribution', security.severities, 8);
renderBarList('securityTypeDistribution', security.event_types, 8);
$('securityRuns').innerHTML = compactRunCards('security-source', 8);
const articles = pipelines.articles || {};
$('articleCreated').textContent = num(articles.created);
$('articleReviews').textContent = num(articles.reviews);
$('articleRejectedFailed').textContent = `${num(articles.rejected)} / ${num(articles.failed)}`;
$('articleResearchReuse').textContent = `${num(articles.inbox_research_hits)} / ${num(articles.web_research_fetches)}`;
const articleBadge = $('articlePipelineBadge');
articleBadge.className = `status-pill ${Number(articles.failed || 0) ? 'warning' : Number(articles.created || 0) ? 'success' : 'neutral'}`;
articleBadge.textContent = `${num(articles.created)} erstellt`;
$('articleClaimSummary').innerHTML = `<b>Claim-Review:</b> ${num(articles.supported_claims)} supported · ${num(articles.partially_supported_claims)} partially supported · ${num(articles.unsupported_claims)} unsupported · ${num(articles.contradicted_claims)} contradicted · ${num(articles.duplicates)} Duplikate · ${num(articles.skipped)} Skips.`;
$('articleRuns').innerHTML = compactRunCards('article', 8);
}
function renderRunStats() {
const stats = state.payload?.history?.run_stats || [];
const body = $('runStatsBody');
if (!stats.length) {
body.innerHTML = '<tr><td colspan="7" class="empty-state">Noch keine auswertbaren Workflow-Laufzeiten.</td></tr>';
return;
}
body.innerHTML = stats.map(item => {
const d = item.duration || {};
const outcome = `<span class="status-pill success">${num(item.successes)} ✓</span> <span class="status-pill warning">${num(item.warnings)} !</span> <span class="status-pill error">${num(item.failures)} ×</span>${Number(item.running || 0) ? ` <span class="status-pill running">${num(item.running)} läuft</span>` : ''}`;
const durationText = Number(d.samples || 0) ? `${duration(d.average_ms)} / ${duration(d.p50_ms)} / ${duration(d.p95_ms)}` : '';
return `<tr><td><b>${esc(item.kind)}</b></td><td>${num(item.runs)}<span class="run-meta">${num(d.samples)} mit Laufzeit</span></td><td>${outcome}</td><td>${Number(d.samples || 0) ? duration(d.total_ms) : ''}</td><td>${durationText}<span class="run-meta">Ø / P50 / P95 · max ${Number(d.samples || 0) ? duration(d.max_ms) : ''}</span></td><td><div class="delta-tags">${statDeltaTags(item)}</div></td><td>${num(item.event_count)}</td></tr>`;
}).join('');
}
function renderTimeline() {
const canvas = $('timelineCanvas');
const data = state.payload?.history?.timeline || [];
$('timelineHint').textContent = `${data.length} Zeitsegmente · ${state.payload.range_hours} Stunden · No-op-Scans verdichtet`;
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))).slice();
const sortMode = $('runSort').value;
if (sortMode === 'duration') filtered.sort((a,b)=>Number(b.duration_ms||0)-Number(a.duration_ms||0));
else if (sortMode === 'mutations') filtered.sort((a,b)=>{const av=a.mutations_known?mutationTotals(a.mutations):{created:-1,updated:0,deleted:0},bv=b.mutations_known?mutationTotals(b.mutations):{created:-1,updated:0,deleted:0};return (bv.created+bv.updated+bv.deleted)-(av.created+av.updated+av.deleted)});
else if (sortMode === 'events') filtered.sort((a,b)=>Number(b.event_count||0)-Number(a.event_count||0));
else filtered.sort((a,b)=>new Date(b.started_at)-new Date(a.started_at));
$('runCount').textContent = `${num(filtered.length)} von ${num(runs.length)} Läufen`;
const body = $('runsBody');
if (!filtered.length) { body.innerHTML='<tr><td colspan="6" class="empty-state">Keine Läufe entsprechen den Filtern.</td></tr>'; 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 `<tr class="run-row" data-run-id="${esc(run.id)}"><td>${clock(run.started_at)}<span class="run-meta">${new Date(run.started_at).toLocaleDateString('de-DE')}</span></td><td><span class="run-title">${esc(run.title)}</span><span class="run-meta">${esc(run.kind)}${run.trigger?` · ${esc(run.trigger)}`:''}</span></td><td><span class="status-pill ${statusClass(run.status)}">${esc(run.verdict||run.status)}</span><span class="run-meta">${esc(run.outcome||'')}</span></td><td><div class="delta-tags">${runDeltaTags(run)}</div></td><td>${esc(metricText)}</td><td>${run.status==='running'?'läuft':duration(run.duration_ms)}</td></tr>${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 `<tr class="run-detail-row"><td colspan="6"><div class="run-detail"><div><h3>Erklärung</h3><p>${esc(run.explanation||'Keine Erklärung vorhanden.')}</p><div class="delta-tags">${runDeltaTags(run)}</div><h3>Messwerte</h3><pre>${esc(JSON.stringify(run.metrics||{},null,2))}</pre><h3>Betroffene IDs</h3><pre>${esc(JSON.stringify({node_ids:run.node_ids||[],edge_ids:run.edge_ids||[]},null,2))}</pre></div><div><h3>Ereignisse dieses Laufs</h3><div class="event-mini-list">${events.map(event=>`<div class="event-mini"><b>${clock(event.activity.timestamp)} · ${esc(event.activity.type)}</b><small>${esc(event.activity.message||event.activity.phase||'')}</small><div class="delta-tags" style="margin-top:5px">${deltaTags(event.point?.delta)}</div><small>globaler Graph-Delta seit dem vorherigen Audit-Event; nicht automatisch diesem Workflow zugeordnet</small>${event.change_count?`<small>${num(event.change_count)} globale Detailänderungen seit dem vorherigen Audit-Event${event.changes_truncated?` · ${num(event.changes_truncated)} gekürzt`:''}</small>`:''}</div>`).join('')}</div></div></div></td></tr>`;
}
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])=>`<div class="bar-row"><span class="bar-label" title="${esc(label)}">${esc(label)}</span><span class="bar-track"><i class="bar-fill" style="width:${Math.max(2,Number(value)/max*100)}%"></i></span><span class="bar-value">${num(value)}</span></div>`).join('') : '<span class="empty-state">Keine Daten</span>';
}
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 `<div class="compact-item"><header><b>${esc(event.activity.message||event.activity.type)}</b><span>${pct(Number(m.semantic_similarity))}</span></header><small>${when(event.activity.timestamp)} · ${num(m.candidate_comparisons||m.comparisons||0)} Vergleiche${m.confidence!==undefined?` · ${pct(m.confidence)} Konfidenz`:''}</small></div>`}).join(''):'<span class="empty-state">Noch keine Näheprüfungen im Zeitraum.</span>';
}
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=>`<div class="compact-item"><header><b>${esc(event.activity.message||event.activity.type)}</b><span>${clock(event.activity.timestamp)}</span></header><small>${esc(event.activity.type)} · ${esc(metadataValue(event.activity.metadata,'model')||'')} ${metadataValue(event.activity.metadata,'dimensions')?`· ${num(metadataValue(event.activity.metadata,'dimensions'))} Dimensionen`:''}</small><div class="delta-tags" style="margin-top:5px">${deltaTags(event.point?.delta)}</div>${event.change_count?`<small>${num(event.change_count)} konkrete Änderungen${event.changes_truncated?` · ${num(event.changes_truncated)} gekürzt`:''}</small>`:''}</div>`).join(''):'<span class="empty-state">Keine Embedding-Ereignisse im Zeitraum.</span>';
}
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=>`<div class="compact-item"><header><b>${esc(run.title)}</b><span class="status-pill ${statusClass(run.status)}">${esc(run.verdict||run.status)}</span></header><small>${when(run.started_at)} · ${duration(run.duration_ms)} · ${num(run.event_count)} Events</small><p>${esc(run.explanation||run.outcome||'')}</p><div class="delta-tags">${runDeltaTags(run)}</div></div>`).join(''):'<span class="empty-state">Keine Rechercheläufe im Zeitraum.</span>';
}
function renderNewest() {
const graph=state.payload.graph||{};
$('newestNodes').innerHTML=(graph.newest_nodes||[]).map(node=>`<div class="entity-item"><header><b>${esc(node.label)}</b><small>${when(node.updated_at)}</small></header><p>${esc(node.summary||'Keine Zusammenfassung')}</p><div class="entity-tags"><span>${esc(node.kind)}</span><span>${esc(node.status||'kein Status')}</span><span>${esc(node.origin)}</span>${node.metadata?.source?`<span>source: ${esc(node.metadata.source)}</span>`:''}</div><code>${esc(node.id)}</code></div>`).join('')||'<span class="empty-state">Keine Nodes vorhanden.</span>';
$('newestEdges').innerHTML=(graph.newest_edges||[]).map(edge=>`<div class="entity-item"><header><b>${esc(edge.type)}</b><small>${when(edge.updated_at)}</small></header><p>${esc(edge.explanation||'Keine Erklärung')} ${edge.confidence?`· Konfidenz ${pct(edge.confidence)}`:''}</p><div class="entity-tags"><span>${esc(edge.status||'kein Status')}</span><span>${esc(edge.origin)}</span></div><code>${esc(edge.source)}${esc(edge.target)}</code></div>`).join('')||'<span class="empty-state">Keine Edges vorhanden.</span>';
}
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 = '<tr><td colspan="7" class="empty-state">Keine konkreten Änderungen entsprechen den Filtern.</td></tr>';
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 `<tr class="change-row" data-event-id="${esc(change.event_id)}"><td>${when(change.timestamp)}</td><td><span class="change-action ${esc(change.action)}">${esc(changeActionLabel(change.action))}</span></td><td><b>${esc(change.entity_kind)}</b>${details.status?`<span class="run-meta">${esc(details.status)}</span>`:''}</td><td><code>${esc(change.entity_id)}</code>${subtitle?`<span class="run-meta">${esc(subtitle)}</span>`:''}</td><td>${esc(relation||'')}<span class="run-meta">${esc(technical)}</span></td><td>${num(change.graph_version)}</td><td><button class="event-link" type="button" data-event-id="${esc(change.event_id)}">${esc(change.event_id || '')}</button></td></tr>`;
}).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)));
const selection=state.payload.history?.event_selection||{}; $('eventCount').textContent=`${num(filtered.length)} von ${num(events.length)} geladen · ${num(selection.persisted_events??state.payload.history?.raw_event_count)} persistiert`;
$('eventsList').innerHTML=filtered.length?filtered.map(event=>{const a=event.activity||{},open=state.openEvents.has(a.id);return `<article class="event-card ${open?'open':''}" data-event-id="${esc(a.id)}"><div class="event-summary"><span class="event-time">${when(a.timestamp)}</span><span class="event-type"><i class="status-dot ${eventSeverity(a)}"></i> ${esc(a.type)}</span><span class="event-message">${esc(a.message||a.query||a.phase||'')}</span><span class="event-delta"><span class="delta-tags">${deltaTags(event.point?.delta)}</span></span></div><div class="event-details"><div><b>Aktivität</b>${a.query?`<div class="query-box">${esc(a.query)}</div>`:''}<pre>${esc(JSON.stringify(a,null,2))}</pre></div><div><b>Graph-Checkpoint unmittelbar nach dem Event</b><pre>${esc(JSON.stringify({...event.point,change_count:event.change_count,changes_truncated:event.changes_truncated||0},null,2))}</pre></div></div></article>`}).join(''):'<div class="empty-state">Keine Ereignisse entsprechen den Filtern.</div>';
$('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);$('runSort').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();
})();