Visualize AI debug runs with phase bar charts
This commit is contained in:
105
web/ai-debug.css
105
web/ai-debug.css
@@ -154,6 +154,107 @@
|
|||||||
margin-bottom: 10px;
|
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 {
|
.ai-debug-json {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
@@ -177,4 +278,8 @@
|
|||||||
.ai-debug-json {
|
.ai-debug-json {
|
||||||
max-height: 42vh;
|
max-height: 42vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-debug-bar-row {
|
||||||
|
grid-template-columns: minmax(100px, 140px) minmax(40px, 1fr) auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
web/ai-debug.js
157
web/ai-debug.js
@@ -21,6 +21,7 @@
|
|||||||
const tableBody = document.getElementById('aiDebugTableBody');
|
const tableBody = document.getElementById('aiDebugTableBody');
|
||||||
const statusEl = document.getElementById('aiDebugStatus');
|
const statusEl = document.getElementById('aiDebugStatus');
|
||||||
const detailMeta = document.getElementById('aiDebugDetailMeta');
|
const detailMeta = document.getElementById('aiDebugDetailMeta');
|
||||||
|
const detailViz = document.getElementById('aiDebugDetailViz');
|
||||||
const detailJson = document.getElementById('aiDebugDetailJson');
|
const detailJson = document.getElementById('aiDebugDetailJson');
|
||||||
const refreshBtn = document.getElementById('aiDebugRefreshBtn');
|
const refreshBtn = document.getElementById('aiDebugRefreshBtn');
|
||||||
const statusFilter = document.getElementById('aiDebugStatusFilter');
|
const statusFilter = document.getElementById('aiDebugStatusFilter');
|
||||||
@@ -90,6 +91,14 @@
|
|||||||
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
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) {
|
function getBackendTotalMs(item) {
|
||||||
return item
|
return item
|
||||||
&& item.backend_timings
|
&& item.backend_timings
|
||||||
@@ -117,6 +126,11 @@
|
|||||||
return;
|
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) => {
|
state.traces.forEach((item) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.dataset.traceId = item.trace_id;
|
tr.dataset.traceId = item.trace_id;
|
||||||
@@ -124,10 +138,20 @@
|
|||||||
tr.classList.add('is-selected');
|
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 = `
|
tr.innerHTML = `
|
||||||
<td>${formatDate(item.created_at)}</td>
|
<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><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(getBackendTotalMs(item))}</td>
|
||||||
<td>${formatMs(getAiRequestMs(item))}</td>
|
<td>${formatMs(getAiRequestMs(item))}</td>
|
||||||
<td><code>${compactId(item.flow_id)}</code> / <code>${compactId(item.trace_id)}</code></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) {
|
function renderDetail(trace) {
|
||||||
if (!detailMeta || !detailJson) {
|
if (!detailMeta || !detailJson) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!trace) {
|
if (!trace) {
|
||||||
detailMeta.textContent = 'Bitte einen Eintrag auswählen.';
|
detailMeta.textContent = 'Bitte einen Eintrag auswählen.';
|
||||||
|
renderDetailVisualization(null);
|
||||||
detailJson.textContent = '';
|
detailJson.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
detailMeta.textContent = `${trace.status || '—'} · ${formatDate(trace.created_at)} · Trace ${trace.trace_id || '—'}`;
|
detailMeta.textContent = `${trace.status || '—'} · ${formatDate(trace.created_at)} · Trace ${trace.trace_id || '—'}`;
|
||||||
|
renderDetailVisualization(trace);
|
||||||
detailJson.textContent = JSON.stringify(trace, null, 2);
|
detailJson.textContent = JSON.stringify(trace, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1453,6 +1453,7 @@
|
|||||||
<section class="ai-debug-detail-panel">
|
<section class="ai-debug-detail-panel">
|
||||||
<h3>Ablaufdetails</h3>
|
<h3>Ablaufdetails</h3>
|
||||||
<div id="aiDebugDetailMeta" class="ai-debug-detail-meta">Bitte einen Eintrag auswählen.</div>
|
<div id="aiDebugDetailMeta" class="ai-debug-detail-meta">Bitte einen Eintrag auswählen.</div>
|
||||||
|
<div id="aiDebugDetailViz" class="ai-debug-viz"></div>
|
||||||
<pre id="aiDebugDetailJson" class="ai-debug-json"></pre>
|
<pre id="aiDebugDetailJson" class="ai-debug-json"></pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user