/** * 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} _snapshotState: null, // deep-copy von _stateByKey vor Refresh-Start (fuer Cancel-Restore) _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)); // Erfolg: API-State neu laden (finaler Stand sichtbar) WS.on('refresh_complete', (msg) => this._onRefreshDoneSuccess(msg)); // Cancel/Error: vor-Refresh-Snapshot zurueckspielen, damit Pipeline nicht im Mix-Zustand stehen bleibt WS.on('refresh_cancelled', (msg) => this._onRefreshDoneCancel(msg)); WS.on('refresh_error', (msg) => this._onRefreshDoneError(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._snapshotState = null; // Snapshot ist immer lagen-spezifisch 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); } }, /** * Wird vom Frontend gerufen, wenn ein Refresh angestossen wurde (queued). * Macht einen Snapshot des aktuellen Pipeline-Stands (zur spaeteren Wiederherstellung * bei Cancel/Error) und setzt dann alle Steps auf "pending" - damit der User sieht: * "neuer Refresh laeuft an, alte gruene Haekchen sind nicht mehr aktuell". */ beginQueue(incidentId) { if (this._incidentId !== incidentId) return; // andere Lage offen if (!this._definition) return; // noch keine Pipeline-Definition geladen // Aktuellen Stand sichern (deep-copy). Bei Mehrfach-Refresh ohne Cancel // dazwischen wird der Snapshot bewusst ueberschrieben - er soll immer // der "Stand kurz vor diesem Refresh" sein. this._snapshotState = JSON.parse(JSON.stringify(this._stateByKey)); // Alle Steps auf pending setzen this._definition.forEach(s => { if (this._stateByKey[s.key]) { this._stateByKey[s.key].status = 'pending'; } else { this._stateByKey[s.key] = { status: 'pending', count_value: null, count_secondary: null, pass_number: 1 }; } }); this._render(); this._renderMini(); }, /** Restauriert den letzten Snapshot. Rueckgabe: true bei Erfolg, false wenn keiner da war. */ _restoreSnapshot() { if (!this._snapshotState) return false; this._stateByKey = this._snapshotState; this._snapshotState = null; this._render(); this._renderMini(); return true; }, _onRefreshDoneSuccess(msg) { if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return; this._snapshotState = null; // verworfen, neuer Stand wird vom API geladen // Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen setTimeout(() => { if (this._incidentId != null) this.bindToIncident(this._incidentId); }, 600); }, _onRefreshDoneCancel(msg) { if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return; if (!this._restoreSnapshot()) { // Kein Snapshot vorhanden (z.B. Page-Reload mitten im Refresh) -> wie bisher API-Reload setTimeout(() => { if (this._incidentId != null) this.bindToIncident(this._incidentId); }, 600); } }, _onRefreshDoneError(msg) { // Wie Cancel: vorheriger Stand zurueck (nicht im Mix-Zustand stehenbleiben) this._onRefreshDoneCancel(msg); }, /** 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 = `