Pipeline laeuft jetzt zickzack: Reihe 1 von links nach rechts, U-Turn nach unten, Reihe 2 von rechts nach links, U-Turn nach unten, Reihe 3 wieder von links nach rechts. Karte waechst auf benoetigte Hoehe statt horizontalem Scrollen. - pipeline.js: Bloecke werden in Dreier-Gruppen aufgeteilt, Direction ltr/rtl wechselt pro Reihe. Zwischen Reihen rendert ein SVG-U-Turn-Pfeil (Bogen mit Pfeilkopf) die Verbindung. Daten-Fluss-Animation (is-flowing) funktioniert sowohl auf Inner-Pfeilen als auch auf U-Turns. - CSS: .pipeline-row mit flex-direction abhaengig von data-direction. rtl-Reihen kippen Pfeilkopf und Animation in entgegengesetzte Richtung. U-Turn-Pfad als SVG mit stroke-dasharray-Animation bei aktivem Fluss. - Mobile (<900px): Snake aufgeloest, alle Reihen werden vertikal gestapelt, U-Turns ausgeblendet — bestehende Vertikal-Stilistik bleibt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
518 Zeilen
23 KiB
JavaScript
518 Zeilen
23 KiB
JavaScript
/**
|
|
* 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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>',
|
|
rss: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.5"/></svg>',
|
|
'copy-x': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="13" height="13" rx="2"/><path d="M8 21h11a2 2 0 0 0 2-2V8"/><path d="M11 11l4 4M15 11l-4 4"/></svg>',
|
|
scale: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 8h14"/><path d="M5 8l-3 7h6z"/><path d="M19 8l-3 7h6z"/></svg>',
|
|
'map-pin': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s7-7 7-13a7 7 0 0 0-14 0c0 6 7 13 7 13z"/><circle cx="12" cy="9" r="2.5"/></svg>',
|
|
'file-text': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h8M8 9h2"/></svg>',
|
|
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>',
|
|
'check-circle': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 12l3 3 5-6"/></svg>',
|
|
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>',
|
|
},
|
|
|
|
/** 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 = `<div class="pipeline-row" data-direction="${row.direction}">`;
|
|
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 += `<div class="pipeline-arrow" data-from="${s.key}" data-arrow-type="inner"></div>`;
|
|
}
|
|
});
|
|
rowHtml += '</div>';
|
|
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 = `<div class="pipeline-track">${trackHtml}</div>`;
|
|
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
|
|
? `<div class="pipeline-loop" title="Mehrfach-Durchlauf"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg></div>`
|
|
: '';
|
|
const icon = this._icons[stepDef.icon] || this._icons.search;
|
|
return `
|
|
<div class="pipeline-block status-${status}" data-step-key="${stepDef.key}" tabindex="0" aria-label="${this._escape(stepDef.label)}">
|
|
<div class="pipeline-block-icon">${icon}</div>
|
|
<div class="pipeline-block-title">${this._escape(stepDef.label)}</div>
|
|
<div class="pipeline-block-count">${this._formatCount(stepDef.key, cv, cs, status)}</div>
|
|
<div class="pipeline-block-check" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-11"/></svg>
|
|
</div>
|
|
${loopMark}
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
/** 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'
|
|
? '<polyline points="14,26 8,32 14,38"/>'
|
|
: '<polyline points="86,26 92,32 86,38"/>';
|
|
return `
|
|
<div class="pipeline-uturn" data-side="${side}" data-from="${fromKey}" data-arrow-type="uturn" aria-hidden="true">
|
|
<svg viewBox="0 0 100 44" preserveAspectRatio="none">
|
|
<path d="${path}" class="pipeline-uturn-path"/>
|
|
<g class="pipeline-uturn-head">${head}</g>
|
|
</svg>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
/** 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 = `
|
|
<div class="pipeline-popup-inner">
|
|
<div class="pipeline-popup-title">${this._escape(def.label)}</div>
|
|
<div class="pipeline-popup-text">${this._escape(def.tooltip || '')}</div>
|
|
<button class="pipeline-popup-close" aria-label="Schliessen">×</button>
|
|
</div>
|
|
`;
|
|
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) ? '<span class="pipeline-mini-sep" aria-hidden="true"></span>' : '';
|
|
return `<span class="pipeline-mini-block status-${status}" data-step-key="${s.key}" title="${this._escape(s.label)}">${icon}</span>${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 = `<div class="pipeline-empty">${msg}</div>`;
|
|
// 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 '<span class="count-status">erledigt</span>';
|
|
if (status === 'active') return '<span class="count-status">läuft...</span>';
|
|
if (status === 'error') return '<span class="count-status">Fehler</span>';
|
|
return '<span class="count-status">—</span>';
|
|
}
|
|
if (status === 'pending') return '<span class="count-status">—</span>';
|
|
if (status === 'active') return '<span class="count-status">läuft...</span>';
|
|
if (status === 'error') return '<span class="count-status">Fehler</span>';
|
|
if (cv == null) return '<span class="count-status">—</span>';
|
|
|
|
switch (stepKey) {
|
|
case 'sources_review':
|
|
return `${cv} Quellen geprüft`;
|
|
case 'collect':
|
|
return cs != null
|
|
? `${cv} Meldungen<small> aus ${cs} Quellen</small>`
|
|
: `${cv} Meldungen`;
|
|
case 'dedup':
|
|
return cs != null
|
|
? `${cv} Duplikate<small> (${cs} verbleiben)</small>`
|
|
: `${cv} Duplikate`;
|
|
case 'relevance':
|
|
return cs != null && cs > 0
|
|
? `${cv} relevant<small> von ${cs}</small>`
|
|
: `${cv} relevant`;
|
|
case 'geoparsing':
|
|
return cs != null
|
|
? `${cv} Orte<small> aus ${cs} Meldungen</small>`
|
|
: `${cv} Orte erkannt`;
|
|
case 'factcheck':
|
|
return cs != null
|
|
? `${cv} neue Fakten<small> (${cs} gesamt)</small>`
|
|
: `${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());
|