diff --git a/web/ai-debug.css b/web/ai-debug.css
index d9b2679..e7066be 100644
--- a/web/ai-debug.css
+++ b/web/ai-debug.css
@@ -154,6 +154,107 @@
margin-bottom: 10px;
}
+.ai-debug-inline-metric {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ai-debug-inline-bar {
+ display: block;
+ width: 100%;
+ height: 6px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ overflow: hidden;
+}
+
+.ai-debug-inline-bar > span {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #0ea5e9 0%, #0284c7 100%);
+}
+
+.ai-debug-viz {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+
+.ai-debug-viz-empty {
+ color: #64748b;
+ font-size: 13px;
+}
+
+.ai-debug-viz-group {
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 10px;
+ background: #f8fafc;
+}
+
+.ai-debug-viz-group h4 {
+ margin: 0 0 8px;
+ font-size: 13px;
+ color: #1e293b;
+}
+
+.ai-debug-bars {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+}
+
+.ai-debug-bar-row {
+ display: grid;
+ grid-template-columns: minmax(120px, 180px) minmax(60px, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+}
+
+.ai-debug-bar-label {
+ font-size: 12px;
+ color: #334155;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ai-debug-bar-track {
+ display: block;
+ width: 100%;
+ height: 10px;
+ border-radius: 999px;
+ background: #dbeafe;
+ overflow: hidden;
+}
+
+.ai-debug-bar-fill {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #38bdf8 0%, #2563eb 100%);
+}
+
+.ai-debug-bar-value {
+ font-size: 12px;
+ color: #0f172a;
+ white-space: nowrap;
+}
+
+.ai-debug-bar-row--peak .ai-debug-bar-track {
+ background: #fee2e2;
+}
+
+.ai-debug-bar-row--peak .ai-debug-bar-fill {
+ background: linear-gradient(90deg, #fb7185 0%, #dc2626 100%);
+}
+
.ai-debug-json {
margin: 0;
max-height: 60vh;
@@ -177,4 +278,8 @@
.ai-debug-json {
max-height: 42vh;
}
+
+ .ai-debug-bar-row {
+ grid-template-columns: minmax(100px, 140px) minmax(40px, 1fr) auto;
+ }
}
diff --git a/web/ai-debug.js b/web/ai-debug.js
index d863493..07d7214 100644
--- a/web/ai-debug.js
+++ b/web/ai-debug.js
@@ -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 = `
${formatDate(item.created_at)} |
${item.status || '—'} |
- ${formatMs(item.total_duration_ms)} |
+
+
+ ${formatMs(item.total_duration_ms)}
+
+
+ |
${formatMs(getBackendTotalMs(item))} |
${formatMs(getAiRequestMs(item))} |
${compactId(item.flow_id)} / ${compactId(item.trace_id)} |
@@ -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 `
+
+ ${entry.label}
+
+
+
+ ${formatMs(entry.value)}
+
+ `;
+ }).join('');
+
+ return `
+
+ `;
+ }
+
+ 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 = 'Keine Timing-Daten für diesen Lauf verfügbar.
';
+ 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);
}
diff --git a/web/index.html b/web/index.html
index 6e706a4..702e9de 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1453,6 +1453,7 @@
Ablaufdetails
Bitte einen Eintrag auswählen.
+