All checks were successful
release-tag / release-image (push) Successful in 2m43s
530 lines
43 KiB
JavaScript
530 lines
43 KiB
JavaScript
(() => {
|
||
'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(`<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();
|
||
})();
|