/**
* Pipeline-Modul: Visualisierung der Analysepipeline pro Lage.
*
* - Liest Pipeline-Definition + letzten Refresh-Stand vom Backend
* (GET /api/incidents/{id}/pipeline)
* - Hört auf WebSocket-Events vom Typ "pipeline_step" und animiert Live
* den jeweils aktiven Schritt
* - Bei Lagen-Wechsel wird die Visualisierung an die neue Lage neu gebunden
*
* Stilkonzept:
* - Blöcke = Karten mit Icon + Titel + Zahl
* - Verbindungspfeile als SVG zwischen den Blöcken
* - Aktiver Block: pulsierender Glow (CSS-Klasse .is-active)
* - Fertiger Block: Häkchen + dezente Outline (.is-done)
* - Übersprungener Block: ausgeblendet (laut Anforderung)
* - Multi-Pass (Research): am letzten Block leuchtet ein Schleifen-Pfeil auf
*/
const Pipeline = {
_incidentId: null,
_definition: null, // PIPELINE_STEPS vom Backend
_stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number}
_isResearch: false,
_passTotal: 1,
_lastRefreshHeader: null,
_hoverTooltipEl: null,
_isLoading: false,
_wsBound: false,
_icons: {
search: '',
rss: '',
'copy-x': '',
scale: '',
'map-pin': '',
'file-text': '',
shield: '',
'check-circle': '',
bell: '',
},
/** Wird einmal beim Seitenstart aufgerufen, hängt sich an WebSocket. */
init() {
if (this._wsBound) return;
if (typeof WS !== 'undefined' && WS.on) {
WS.on('pipeline_step', (msg) => this._onWsStep(msg));
// Bei Refresh-Complete den finalen Stand neu laden, damit Zahlen gefroren sichtbar bleiben
WS.on('refresh_complete', (msg) => this._onRefreshDone(msg));
WS.on('refresh_cancelled', (msg) => this._onRefreshDone(msg));
WS.on('refresh_error', (msg) => this._onRefreshDone(msg));
this._wsBound = true;
}
// Hover-Tooltip-Element vorbereiten
if (!this._hoverTooltipEl) {
const t = document.createElement('div');
t.className = 'pipeline-tooltip';
t.setAttribute('role', 'tooltip');
document.body.appendChild(t);
this._hoverTooltipEl = t;
}
// Klick auf Body schliesst Tooltip-Popup
document.addEventListener('click', (e) => {
if (!e.target.closest('.pipeline-block') && !e.target.closest('.pipeline-popup')) {
this._closePopup();
}
});
},
/** Bindet die Pipeline an eine Lage. Lädt Daten und rendert. */
async bindToIncident(incidentId) {
this._incidentId = incidentId;
this._stateByKey = {};
this._isResearch = false;
this._passTotal = 1;
this._lastRefreshHeader = null;
this._renderEmpty('Lade...');
if (incidentId == null) return;
this._isLoading = true;
try {
const data = await API.getPipeline(incidentId);
// Lagen-Wechsel waehrend Request: alte Antwort verwerfen
if (this._incidentId !== incidentId) return;
this._definition = data.steps_definition || [];
this._isResearch = !!data.is_research;
this._lastRefreshHeader = data.last_refresh || null;
this._passTotal = (data.last_refresh && data.last_refresh.pass_total) || 1;
// Letzten Stand pro step_key konsolidieren (bei Multi-Pass: letzter Pass-Eintrag gewinnt)
(data.steps || []).forEach(s => {
const key = s.step_key;
const prev = this._stateByKey[key];
if (!prev || (s.pass_number || 1) >= (prev.pass_number || 1)) {
this._stateByKey[key] = {
status: s.status,
count_value: s.count_value,
count_secondary: s.count_secondary,
pass_number: s.pass_number || 1,
};
}
});
this._render();
this._renderMini();
} catch (e) {
console.warn('Pipeline laden fehlgeschlagen:', e);
this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.');
} finally {
this._isLoading = false;
}
},
/** WebSocket: einzelner Pipeline-Schritt-Status. */
_onWsStep(msg) {
if (!msg || !msg.data) return;
if (this._incidentId == null || msg.incident_id !== this._incidentId) return;
const d = msg.data;
const key = d.step_key;
if (!key) return;
// State aktualisieren, letzter Pass gewinnt
const prev = this._stateByKey[key];
const passNr = d.pass_number || 1;
if (!prev || passNr >= (prev.pass_number || 1)) {
this._stateByKey[key] = {
status: d.status,
count_value: d.count_value !== undefined ? d.count_value : (prev ? prev.count_value : null),
count_secondary: d.count_secondary !== undefined ? d.count_secondary : (prev ? prev.count_secondary : null),
pass_number: passNr,
};
}
// Multi-Pass-Erkennung: pass_number > _passTotal -> erweitern + Loop-Animation triggern
if (passNr > this._passTotal) {
this._passTotal = passNr;
// Schleifen-Pfeil aufflackern
const stage = document.getElementById('pipeline-stage');
if (stage) {
stage.classList.add('is-looping');
setTimeout(() => stage.classList.remove('is-looping'), 1500);
}
}
// Wenn der ERSTE Schritt (sources_review) auf "active" geht, beginnt ein neuer
// Refresh oder ein neuer Multi-Pass-Durchlauf — alle nachfolgenden Schritte auf
// "pending" (grau) zuruecksetzen, damit der User sieht: das ist neu und
// noch nicht durchlaufen. Sonst stehen sie als "done" vom letzten Mal da.
let didReset = false;
if (d.status === 'active' && this._definition && this._definition.length
&& key === this._definition[0].key) {
this._definition.forEach(s => {
if (s.key !== key && this._stateByKey[s.key]) {
this._stateByKey[s.key].status = 'pending';
didReset = true;
}
});
}
if (didReset) {
// Beim Reset alle Bloecke neu zeichnen, nicht nur den aktuellen
this._render();
this._renderMini();
} else {
this._patchBlock(key);
this._patchMiniBlock(key);
}
},
_onRefreshDone(msg) {
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
// Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600);
},
/** Vollbild-Pipeline (Tab "Analysepipeline") als 3x3-Snake rendern. */
_render() {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (!stage) return;
if (meta) meta.textContent = this._formatHeader();
if (sidenote) sidenote.hidden = !this._isResearch;
// Brandneue Lage ohne Refresh
if (!this._lastRefreshHeader) {
this._renderEmpty('Noch nie aktualisiert. Starte den ersten Refresh.');
return;
}
// Sichtbare Blöcke (skipped komplett ausgeblendet, Anforderung 4b)
const visible = (this._definition || []).filter(s => {
const st = this._stateByKey[s.key];
return !st || st.status !== 'skipped';
});
// In Dreier-Reihen aufteilen, Snake-Direction abwechselnd
const ROW_SIZE = 3;
const rows = [];
for (let i = 0; i < visible.length; i += ROW_SIZE) {
rows.push({
steps: visible.slice(i, i + ROW_SIZE),
direction: (rows.length % 2 === 0) ? 'ltr' : 'rtl',
});
}
let trackHtml = '';
rows.forEach((row, rowIdx) => {
const isLastRow = rowIdx === rows.length - 1;
let rowHtml = `
`;
row.steps.forEach((s, i) => {
const isLastBlockOverall = isLastRow && i === row.steps.length - 1;
rowHtml += this._renderBlock(s, isLastBlockOverall);
// Inner-Pfeil zwischen Blöcken einer Reihe (nicht hinter dem letzten)
if (i < row.steps.length - 1) {
rowHtml += `
`;
}
});
rowHtml += '
';
trackHtml += rowHtml;
// U-Turn-Pfeil zwischen dieser und der nächsten Reihe
if (!isLastRow) {
const lastInRow = row.steps[row.steps.length - 1];
const side = row.direction === 'ltr' ? 'right' : 'left';
trackHtml += this._renderUturn(side, lastInRow.key);
}
});
stage.innerHTML = `${trackHtml}
`;
this._bindBlockEvents(stage);
},
_renderBlock(stepDef, isLastOverall) {
const st = this._stateByKey[stepDef.key];
const status = (st && st.status) || 'pending';
const cv = st ? st.count_value : null;
const cs = st ? st.count_secondary : null;
const loopMark = isLastOverall && this._isResearch
? ``
: '';
const icon = this._icons[stepDef.icon] || this._icons.search;
return `
${icon}
${this._escape(stepDef.label)}
${this._formatCount(stepDef.key, cv, cs, status)}
${loopMark}
`;
},
/** Kompakter Reihenwechsel-Pfeil: kurzer ↓ direkt unter dem letzten Block der oberen Reihe. */
_renderUturn(side, fromKey) {
const arrowSvg = `
`;
const spacers = '';
const inner = side === 'right' ? (spacers + arrowSvg) : (arrowSvg + spacers);
return `
${inner}
`;
},
/** Einzelnen Block neu zeichnen (ohne kompletten Re-Render). */
_patchBlock(stepKey) {
const stage = document.getElementById('pipeline-stage');
if (!stage) return;
const def = (this._definition || []).find(s => s.key === stepKey);
if (!def) return;
const st = this._stateByKey[stepKey];
const status = (st && st.status) || 'pending';
// Übersprungene komplett ausblenden -> kompletter Re-Render
if (status === 'skipped') {
this._render();
return;
}
const block = stage.querySelector(`.pipeline-block[data-step-key="${stepKey}"]`);
if (!block) {
// Block fehlt im DOM (z.B. vorher skipped): kompletter Re-Render
this._render();
return;
}
block.className = `pipeline-block status-${status}`;
block.setAttribute('tabindex', '0');
const cv = st ? st.count_value : null;
const cs = st ? st.count_secondary : null;
const cEl = block.querySelector('.pipeline-block-count');
if (cEl) cEl.innerHTML = this._formatCount(stepKey, cv, cs, status);
// Aktiven Pfeil/U-Turn zum nächsten Block markieren (alles mit data-from)
stage.querySelectorAll('.pipeline-arrow, .pipeline-uturn')
.forEach(a => a.classList.remove('is-flowing'));
if (status === 'done') {
const next = stage.querySelector(`[data-from="${stepKey}"]`);
if (next) next.classList.add('is-flowing');
}
},
_bindBlockEvents(stage) {
stage.querySelectorAll('.pipeline-block').forEach(block => {
const key = block.getAttribute('data-step-key');
const def = (this._definition || []).find(s => s.key === key);
if (!def) return;
block.addEventListener('mouseenter', (e) => this._showTooltip(e, def));
block.addEventListener('mouseleave', () => this._hideTooltip());
block.addEventListener('focus', (e) => this._showTooltip(e, def));
block.addEventListener('blur', () => this._hideTooltip());
block.addEventListener('click', (e) => {
e.stopPropagation();
this._openPopup(def);
});
block.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._openPopup(def);
}
});
});
},
_showTooltip(evt, def) {
if (!this._hoverTooltipEl) return;
this._hoverTooltipEl.textContent = def.tooltip || def.label;
this._hoverTooltipEl.classList.add('visible');
const rect = evt.currentTarget.getBoundingClientRect();
const tipW = 280;
let left = rect.left + rect.width / 2 - tipW / 2;
if (left < 8) left = 8;
if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8;
this._hoverTooltipEl.style.left = left + 'px';
this._hoverTooltipEl.style.top = (rect.top - 8) + 'px';
this._hoverTooltipEl.style.transform = 'translateY(-100%)';
},
_hideTooltip() {
if (!this._hoverTooltipEl) return;
this._hoverTooltipEl.classList.remove('visible');
},
_openPopup(def) {
this._closePopup();
const popup = document.createElement('div');
popup.className = 'pipeline-popup';
popup.setAttribute('role', 'dialog');
popup.innerHTML = `
`;
popup.querySelector('.pipeline-popup-close').addEventListener('click', () => this._closePopup());
document.body.appendChild(popup);
// ESC schliesst
this._escListener = (e) => { if (e.key === 'Escape') this._closePopup(); };
document.addEventListener('keydown', this._escListener);
},
_closePopup() {
const existing = document.querySelector('.pipeline-popup');
if (existing) existing.remove();
if (this._escListener) {
document.removeEventListener('keydown', this._escListener);
this._escListener = null;
}
},
/** Mini-Variante (Refresh-Popup): Icons + Status, keine Zahlen, keine Tooltips. */
_renderMini() {
const mini = document.getElementById('progress-pipeline-mini');
if (!mini) return;
if (!this._definition || !this._definition.length) {
mini.innerHTML = '';
return;
}
const visible = this._definition.filter(s => {
const st = this._stateByKey[s.key];
return !st || st.status !== 'skipped';
});
const html = visible.map((s, i) => {
const st = this._stateByKey[s.key];
const status = (st && st.status) || 'pending';
const icon = this._icons[s.icon] || this._icons.search;
const sep = (i < visible.length - 1) ? '' : '';
return `${icon}${sep}`;
}).join('');
mini.innerHTML = html;
},
_patchMiniBlock(stepKey) {
const mini = document.getElementById('progress-pipeline-mini');
if (!mini) return;
const st = this._stateByKey[stepKey];
const status = (st && st.status) || 'pending';
if (status === 'skipped') {
this._renderMini();
return;
}
const el = mini.querySelector(`.pipeline-mini-block[data-step-key="${stepKey}"]`);
if (!el) {
this._renderMini();
return;
}
el.className = `pipeline-mini-block status-${status}`;
},
_renderEmpty(msg) {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (meta) meta.textContent = '';
if (sidenote) sidenote.hidden = true;
if (stage) stage.innerHTML = `${msg}
`;
// Mini im Refresh-Popup zuruecksetzen
const mini = document.getElementById('progress-pipeline-mini');
if (mini) mini.innerHTML = '';
},
_formatHeader() {
const r = this._lastRefreshHeader;
if (!r) return '';
let parts = [];
if (r.started_at) {
const rel = this._relativeTime(r.started_at);
parts.push(rel ? `Letzter Refresh: ${rel}` : `Letzter Refresh: ${r.started_at}`);
}
if (r.duration_sec != null) {
parts.push(`Dauer: ${r.duration_sec} s`);
}
if (r.status === 'running') {
parts = ['Aktualisierung läuft...'];
} else if (r.status === 'cancelled') {
parts.push('abgebrochen');
} else if (r.status === 'error') {
parts.push('mit Fehler beendet');
}
return parts.join(' · ');
},
_relativeTime(dbStr) {
try {
// dbStr ist lokal "YYYY-MM-DD HH:MM:SS"
const d = new Date(dbStr.replace(' ', 'T'));
if (isNaN(d.getTime())) return '';
const diffMs = Date.now() - d.getTime();
const min = Math.floor(diffMs / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h} Std`;
const days = Math.floor(h / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
} catch (e) {
return '';
}
},
_formatCount(stepKey, cv, cs, status) {
// Qualitaetscheck: KEINE Zahlen, nur Status (Anforderung 3 vom User)
if (stepKey === 'qc' || stepKey === 'summary') {
if (status === 'done') return 'erledigt';
if (status === 'active') return 'läuft...';
if (status === 'error') return 'Fehler';
return '-';
}
if (status === 'pending') return '-';
if (status === 'active') return 'läuft...';
if (status === 'error') return 'Fehler';
if (cv == null) return '-';
switch (stepKey) {
case 'sources_review':
return `${cv} Quellen geprüft`;
case 'collect':
return cs != null
? `${cv} Meldungen aus ${cs} Quellen`
: `${cv} Meldungen`;
case 'dedup':
return cs != null
? `${cv} Duplikate (${cs} verbleiben)`
: `${cv} Duplikate`;
case 'relevance':
return cs != null && cs > 0
? `${cv} relevant von ${cs}`
: `${cv} relevant`;
case 'geoparsing':
return cs != null
? `${cv} Orte aus ${cs} Meldungen`
: `${cv} Orte erkannt`;
case 'factcheck':
return cs != null
? `${cv} neue Fakten (${cs} gesamt)`
: `${cv} Fakten geprüft`;
case 'notify':
return cv === 0 ? 'keine versendet' : `${cv} Hinweis${cv === 1 ? '' : 'e'} versendet`;
default:
return `${cv}`;
}
},
_escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
},
};
document.addEventListener('DOMContentLoaded', () => Pipeline.init());