417 lines
12 KiB
JavaScript
417 lines
12 KiB
JavaScript
(function () {
|
|
let active = false;
|
|
let initialized = false;
|
|
|
|
const API_URL = (() => {
|
|
if (window.API_URL) return window.API_URL;
|
|
try {
|
|
return `${window.location.origin}/api`;
|
|
} catch (error) {
|
|
return 'https://fb.srv.medeba-media.de/api';
|
|
}
|
|
})();
|
|
const LOGIN_PAGE = 'login.html';
|
|
|
|
const state = {
|
|
traces: [],
|
|
selectedTraceId: null,
|
|
loading: false
|
|
};
|
|
|
|
const tableBody = document.getElementById('aiDebugTableBody');
|
|
const statusEl = document.getElementById('aiDebugStatus');
|
|
const detailMeta = document.getElementById('aiDebugDetailMeta');
|
|
const detailViz = document.getElementById('aiDebugDetailViz');
|
|
const detailJson = document.getElementById('aiDebugDetailJson');
|
|
const refreshBtn = document.getElementById('aiDebugRefreshBtn');
|
|
const statusFilter = document.getElementById('aiDebugStatusFilter');
|
|
const limitFilter = document.getElementById('aiDebugLimitFilter');
|
|
|
|
function handleUnauthorized(response) {
|
|
if (response && response.status === 401) {
|
|
if (typeof redirectToLogin === 'function') {
|
|
redirectToLogin();
|
|
} else {
|
|
window.location.href = LOGIN_PAGE;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function apiFetchJSON(path, options = {}) {
|
|
const response = await fetch(`${API_URL}${path}`, {
|
|
credentials: 'include',
|
|
...options
|
|
});
|
|
|
|
if (handleUnauthorized(response)) {
|
|
throw new Error('Nicht angemeldet');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(() => ({}));
|
|
throw new Error(payload.error || 'Unbekannter Fehler');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function setStatus(message, isError = false) {
|
|
if (!statusEl) return;
|
|
statusEl.textContent = message || '';
|
|
statusEl.classList.toggle('ai-debug-status--error', !!isError);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '—';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '—';
|
|
return date.toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
}
|
|
|
|
function formatMs(value) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
return '—';
|
|
}
|
|
return `${Math.round(numeric)} ms`;
|
|
}
|
|
|
|
function compactId(value) {
|
|
if (!value || typeof value !== 'string') return '—';
|
|
if (value.length <= 16) return value;
|
|
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
|
}
|
|
|
|
function toPositiveNumber(value) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
return null;
|
|
}
|
|
return numeric;
|
|
}
|
|
|
|
function getBackendTotalMs(item) {
|
|
return item
|
|
&& item.backend_timings
|
|
&& typeof item.backend_timings === 'object'
|
|
? item.backend_timings.totalMs
|
|
: null;
|
|
}
|
|
|
|
function getAiRequestMs(item) {
|
|
return item
|
|
&& item.frontend_timings
|
|
&& typeof item.frontend_timings === 'object'
|
|
? item.frontend_timings.aiRequestMs
|
|
: null;
|
|
}
|
|
|
|
function renderTable() {
|
|
if (!tableBody) return;
|
|
tableBody.innerHTML = '';
|
|
|
|
if (!state.traces.length) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = '<td colspan="6" class="ai-debug-empty">Keine Debug-Läufe gefunden.</td>';
|
|
tableBody.appendChild(tr);
|
|
return;
|
|
}
|
|
|
|
const totals = state.traces
|
|
.map((item) => toPositiveNumber(item.total_duration_ms))
|
|
.filter((value) => value !== null);
|
|
const maxTotal = totals.length ? Math.max(...totals) : 0;
|
|
|
|
state.traces.forEach((item) => {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.traceId = item.trace_id;
|
|
if (item.trace_id === state.selectedTraceId) {
|
|
tr.classList.add('is-selected');
|
|
}
|
|
|
|
const totalMs = toPositiveNumber(item.total_duration_ms);
|
|
const totalBarPercent = maxTotal > 0 && totalMs !== null
|
|
? Math.max(4, Math.round((totalMs / maxTotal) * 100))
|
|
: 0;
|
|
|
|
tr.innerHTML = `
|
|
<td>${formatDate(item.created_at)}</td>
|
|
<td><span class="ai-debug-badge ai-debug-badge--${(item.status || 'unknown').replace(/[^a-z0-9_-]/gi, '')}">${item.status || '—'}</span></td>
|
|
<td>
|
|
<div class="ai-debug-inline-metric">
|
|
<span>${formatMs(item.total_duration_ms)}</span>
|
|
<span class="ai-debug-inline-bar" aria-hidden="true"><span style="width: ${totalBarPercent}%"></span></span>
|
|
</div>
|
|
</td>
|
|
<td>${formatMs(getBackendTotalMs(item))}</td>
|
|
<td>${formatMs(getAiRequestMs(item))}</td>
|
|
<td><code>${compactId(item.flow_id)}</code> / <code>${compactId(item.trace_id)}</code></td>
|
|
`;
|
|
|
|
tableBody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function collectFrontendBars(trace) {
|
|
const front = trace && trace.frontend_timings && typeof trace.frontend_timings === 'object'
|
|
? trace.frontend_timings
|
|
: {};
|
|
|
|
const mapping = [
|
|
['extractPostTextMs', 'Text-Extraktion'],
|
|
['profileLookupMs', 'Profil-Lookup'],
|
|
['aiRequestMs', 'AI-Request'],
|
|
['waitForCommentInputMs', 'Wartezeit Kommentarfeld'],
|
|
['setCommentTextMs', 'Kommentar einfügen'],
|
|
['confirmParticipationMs', 'Auto-Bestätigung'],
|
|
['clipboardWriteMs', 'Clipboard-Fallback'],
|
|
['totalMs', 'Frontend Total']
|
|
];
|
|
|
|
return mapping
|
|
.map(([key, label]) => ({
|
|
key,
|
|
label,
|
|
value: toPositiveNumber(front[key])
|
|
}))
|
|
.filter((entry) => entry.value !== null);
|
|
}
|
|
|
|
function collectBackendBars(trace) {
|
|
const back = trace && trace.backend_timings && typeof trace.backend_timings === 'object'
|
|
? trace.backend_timings
|
|
: {};
|
|
|
|
const mapping = [
|
|
['loadSettingsMs', 'Settings laden'],
|
|
['reactivateCredentialsMs', 'Credentials reaktivieren'],
|
|
['loadCredentialsMs', 'Credentials laden'],
|
|
['buildPromptMs', 'Prompt bauen'],
|
|
['credentialLoopMs', 'Provider-/Credential-Loop'],
|
|
['totalMs', 'Backend Total']
|
|
];
|
|
|
|
return mapping
|
|
.map(([key, label]) => ({
|
|
key,
|
|
label,
|
|
value: toPositiveNumber(back[key])
|
|
}))
|
|
.filter((entry) => entry.value !== null);
|
|
}
|
|
|
|
function collectCredentialBars(trace) {
|
|
const attempts = trace && Array.isArray(trace.backend_attempts)
|
|
? trace.backend_attempts
|
|
: [];
|
|
|
|
return attempts
|
|
.map((attempt, index) => {
|
|
const credential = attempt && attempt.credentialName ? attempt.credentialName : `Credential ${index + 1}`;
|
|
const status = attempt && attempt.status ? attempt.status : 'unknown';
|
|
return {
|
|
key: `credential_${index + 1}`,
|
|
label: `${credential} (${status})`,
|
|
value: toPositiveNumber(attempt && attempt.duration_ms)
|
|
};
|
|
})
|
|
.filter((entry) => entry.value !== null);
|
|
}
|
|
|
|
function renderBarGroup(title, rows) {
|
|
if (!Array.isArray(rows) || !rows.length) {
|
|
return '';
|
|
}
|
|
|
|
const max = Math.max(...rows.map((entry) => entry.value));
|
|
const sorted = [...rows].sort((a, b) => b.value - a.value);
|
|
const peakKey = sorted[0] ? sorted[0].key : null;
|
|
|
|
const items = rows.map((entry) => {
|
|
const width = max > 0
|
|
? Math.max(4, Math.round((entry.value / max) * 100))
|
|
: 0;
|
|
const peakClass = entry.key === peakKey ? ' ai-debug-bar-row--peak' : '';
|
|
return `
|
|
<li class="ai-debug-bar-row${peakClass}">
|
|
<span class="ai-debug-bar-label">${entry.label}</span>
|
|
<span class="ai-debug-bar-track" aria-hidden="true">
|
|
<span class="ai-debug-bar-fill" style="width: ${width}%"></span>
|
|
</span>
|
|
<span class="ai-debug-bar-value">${formatMs(entry.value)}</span>
|
|
</li>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<section class="ai-debug-viz-group">
|
|
<h4>${title}</h4>
|
|
<ul class="ai-debug-bars">
|
|
${items}
|
|
</ul>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderDetailVisualization(trace) {
|
|
if (!detailViz) {
|
|
return;
|
|
}
|
|
|
|
if (!trace) {
|
|
detailViz.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const frontendBars = collectFrontendBars(trace);
|
|
const backendBars = collectBackendBars(trace);
|
|
const credentialBars = collectCredentialBars(trace);
|
|
|
|
const sections = [
|
|
renderBarGroup('Frontend-Phasen', frontendBars),
|
|
renderBarGroup('Backend-Phasen', backendBars),
|
|
renderBarGroup('Credential-Attempts', credentialBars)
|
|
].filter(Boolean);
|
|
|
|
if (!sections.length) {
|
|
detailViz.innerHTML = '<div class="ai-debug-viz-empty">Keine Timing-Daten für diesen Lauf verfügbar.</div>';
|
|
return;
|
|
}
|
|
|
|
detailViz.innerHTML = sections.join('');
|
|
}
|
|
|
|
function renderDetail(trace) {
|
|
if (!detailMeta || !detailJson) {
|
|
return;
|
|
}
|
|
if (!trace) {
|
|
detailMeta.textContent = 'Bitte einen Eintrag auswählen.';
|
|
renderDetailVisualization(null);
|
|
detailJson.textContent = '';
|
|
return;
|
|
}
|
|
|
|
detailMeta.textContent = `${trace.status || '—'} · ${formatDate(trace.created_at)} · Trace ${trace.trace_id || '—'}`;
|
|
renderDetailVisualization(trace);
|
|
detailJson.textContent = JSON.stringify(trace, null, 2);
|
|
}
|
|
|
|
async function loadTraceDetail(traceId) {
|
|
if (!traceId) {
|
|
renderDetail(null);
|
|
return;
|
|
}
|
|
try {
|
|
const trace = await apiFetchJSON(`/ai/debug-traces/${encodeURIComponent(traceId)}`);
|
|
if (!active) return;
|
|
renderDetail(trace);
|
|
} catch (error) {
|
|
if (!active) return;
|
|
setStatus(`Details konnten nicht geladen werden: ${error.message}`, true);
|
|
}
|
|
}
|
|
|
|
async function loadTraces() {
|
|
if (!active || state.loading) {
|
|
return;
|
|
}
|
|
|
|
state.loading = true;
|
|
setStatus('Lade Debug-Läufe...');
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
const status = statusFilter ? statusFilter.value.trim() : '';
|
|
const limit = limitFilter ? parseInt(limitFilter.value, 10) : 50;
|
|
if (status) {
|
|
params.set('status', status);
|
|
}
|
|
if (Number.isFinite(limit) && limit > 0) {
|
|
params.set('limit', String(limit));
|
|
}
|
|
|
|
const data = await apiFetchJSON(`/ai/debug-traces?${params.toString()}`);
|
|
if (!active) return;
|
|
|
|
state.traces = Array.isArray(data.items) ? data.items : [];
|
|
if (!state.selectedTraceId || !state.traces.some((item) => item.trace_id === state.selectedTraceId)) {
|
|
state.selectedTraceId = state.traces.length ? state.traces[0].trace_id : null;
|
|
}
|
|
|
|
renderTable();
|
|
await loadTraceDetail(state.selectedTraceId);
|
|
setStatus(`${state.traces.length} Lauf/Läufe geladen.`);
|
|
} catch (error) {
|
|
if (!active) return;
|
|
state.traces = [];
|
|
renderTable();
|
|
renderDetail(null);
|
|
setStatus(`Debug-Läufe konnten nicht geladen werden: ${error.message}`, true);
|
|
} finally {
|
|
state.loading = false;
|
|
}
|
|
}
|
|
|
|
function setupEvents() {
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => loadTraces());
|
|
}
|
|
|
|
if (statusFilter) {
|
|
statusFilter.addEventListener('change', () => loadTraces());
|
|
}
|
|
|
|
if (limitFilter) {
|
|
limitFilter.addEventListener('change', () => loadTraces());
|
|
}
|
|
|
|
if (tableBody) {
|
|
tableBody.addEventListener('click', (event) => {
|
|
const row = event.target.closest('tr[data-trace-id]');
|
|
if (!row) return;
|
|
const traceId = row.dataset.traceId;
|
|
if (!traceId) return;
|
|
state.selectedTraceId = traceId;
|
|
renderTable();
|
|
loadTraceDetail(traceId);
|
|
});
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
if (initialized) return;
|
|
setupEvents();
|
|
initialized = true;
|
|
}
|
|
|
|
function activate() {
|
|
init();
|
|
active = true;
|
|
loadTraces();
|
|
}
|
|
|
|
function deactivate() {
|
|
active = false;
|
|
}
|
|
|
|
window.AIDebugPage = {
|
|
activate,
|
|
deactivate
|
|
};
|
|
|
|
const section = document.querySelector('[data-view="ai-debug"]');
|
|
if (section && section.classList.contains('app-view--active')) {
|
|
activate();
|
|
}
|
|
})();
|