Visualize AI debug runs with phase bar charts

This commit is contained in:
2026-02-20 14:16:29 +01:00
parent af3b07b80f
commit 46fc27600e
3 changed files with 262 additions and 1 deletions

View File

@@ -21,6 +21,7 @@
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');
@@ -90,6 +91,14 @@
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
@@ -117,6 +126,11 @@
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;
@@ -124,10 +138,20 @@
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>${formatMs(item.total_duration_ms)}</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>
@@ -137,17 +161,148 @@
});
}
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);
}