/** * 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 ein neuer Pass startet (pass_number > prev und status="active" beim ERSTEN step): // alle Schritte zurück auf pending setzen, damit die Animation neu durchläuft. if (d.status === 'active' && this._definition && this._definition.length && key === this._definition[0].key && passNr > 1 && (!prev || prev.pass_number < passNr)) { // Alle anderen Steps in "pending" zurueck (visuell), Werte behalten wir this._definition.forEach(s => { if (s.key !== key && this._stateByKey[s.key]) { this._stateByKey[s.key].status = 'pending'; } }); } 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}
`; }, /** U-Turn-Pfeil zwischen zwei Reihen (Snake-Übergang). */ _renderUturn(side, fromKey) { // SVG-Bogen + Pfeilkopf, side="right" startet rechts oben, "left" startet links oben. // viewBox: 100 wide, 44 high const path = side === 'right' ? 'M 92 0 L 92 18 A 14 14 0 0 1 78 32 L 8 32' : 'M 8 0 L 8 18 A 14 14 0 0 0 22 32 L 92 32'; const head = side === 'right' ? '' : ''; return ` `; }, /** 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 = `
${this._escape(def.label)}
${this._escape(def.tooltip || '')}
`; 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());