Beim ersten Schritt (sources_review) eines neuen Refreshs werden alle nachfolgenden Schritte sichtbar auf "pending" (grau) zurückgesetzt. Vorher hingen sie weiterhin als "done" vom letzten Refresh in grün herum, während die Pipeline schon einen neuen Durchlauf zeigte. - Bedingung in pipeline.js entschärft: nicht mehr nur bei pass_number > 1 (Multi-Pass), sondern bei jedem ersten Schritt-Active - Bei Reset wird das ganze Stage neu gezeichnet (nicht nur der einzelne Block), damit die zurückgesetzten Schritte tatsächlich grau erscheinen - Greift sowohl bei normalem Refresh als auch bei Multi-Pass-Wechsel einer Research-Lage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
525 Zeilen
24 KiB
JavaScript
525 Zeilen
24 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 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 = `<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>
|
|
`;
|
|
},
|
|
|
|
/** Kompakter Reihenwechsel-Pfeil: kurzer ↓ direkt unter dem letzten Block der oberen Reihe. */
|
|
_renderUturn(side, fromKey) {
|
|
const arrowSvg = `
|
|
<div class="uturn-arrow">
|
|
<svg viewBox="0 0 24 32" preserveAspectRatio="xMidYMid meet">
|
|
<path d="M 12 2 L 12 24" class="pipeline-uturn-path"/>
|
|
<polyline points="6,18 12,24 18,18" class="pipeline-uturn-head"/>
|
|
</svg>
|
|
</div>`;
|
|
const spacers = '<span class="uturn-spacer"></span><span class="uturn-spacer"></span>';
|
|
const inner = side === 'right' ? (spacers + arrowSvg) : (arrowSvg + spacers);
|
|
return `
|
|
<div class="pipeline-uturn" data-side="${side}" data-from="${fromKey}" data-arrow-type="uturn" aria-hidden="true">
|
|
${inner}
|
|
</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());
|