Compare commits

...

2 Commits

Author SHA1 Message Date
localai-org-maint-bot
70f1eb3e77 fix(ui): expand traces without IDs
Keep ID-based expansion stable across refreshes while falling back to the row index for backend trace summaries that do not carry an ID.

Assisted-by: Codex:gpt-5 [systematic-debugging]
2026-08-02 03:06:00 +00:00
localai-org-maint-bot
d2ed733ad0 fix(ui): keep trace expansion stable during refresh
Track expanded traces by their stable IDs instead of table indexes so polling cannot move an open detail panel to a newly inserted row. Use the same IDs for React keys and cover the prepend-on-refresh case in Playwright.

Fixes #11277

Assisted-by: Codex:gpt-5
2026-08-01 16:06:37 +00:00
2 changed files with 46 additions and 18 deletions

View File

@@ -79,4 +79,30 @@ test.describe('Traces - bounded list and on-demand detail', () => {
await expect(page.locator('text=hello from the response body')).toBeVisible()
await expect(page.locator('text=203.0.113.9').first()).toBeVisible()
})
test('keeps the expanded trace open when a refresh prepends a new row', async ({ page }) => {
await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
await expect(page.locator('text=hello from the request body')).toBeVisible()
await page.route('**/api/traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
headers: { 'X-Total-Count': '843' },
body: JSON.stringify([
{
id: '8',
request: { method: 'GET', path: '/v1/models', body: null },
response: { status: 200, body: null },
},
...LIST_BODY,
]),
})
})
await page.getByRole('button', { name: 'Refresh' }).click()
await expect(page.locator('text=hello from the request body')).toBeVisible()
const originalRow = page.locator('tr', { hasText: '/v1/chat/completions' }).first()
await expect(originalRow.locator('i.fa-chevron-down')).toBeVisible()
})
})

View File

@@ -342,7 +342,7 @@ export default function Traces() {
const [apiCount, setApiCount] = useState(0)
const [backendCount, setBackendCount] = useState(0)
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
const [expandedTraceId, setExpandedTraceId] = useState(null)
// detail holds the full record for the currently expanded row, fetched on
// demand from /api/traces/:id (the list response omits the bodies).
const [detail, setDetail] = useState(null)
@@ -360,7 +360,8 @@ export default function Traces() {
duration: (a, b) => (a.duration || 0) - (b.duration || 0),
}
const toggleSort = (key) => {
setExpandedRow(null)
setExpandedTraceId(null)
setDetail(null)
setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' })
}
const sortableTh = (key, label, props = {}) => (
@@ -433,20 +434,21 @@ export default function Traces() {
useEffect(() => {
setLoading(true)
setExpandedRow(null)
setExpandedTraceId(null)
setDetail(null)
fetchTraces()
}, [fetchTraces])
// Expanding a row pulls the full record (bodies, data fields, audio
// snippets) that the list response deliberately omits.
const toggleRow = useCallback(async (index, row) => {
if (expandedRow === index) {
setExpandedRow(null)
const toggleRow = useCallback(async (row, index) => {
const traceKey = row?.id ?? index
if (expandedTraceId === traceKey) {
setExpandedTraceId(null)
setDetail(null)
return
}
setExpandedRow(index)
setExpandedTraceId(traceKey)
setDetail(null)
if (!row?.id) return
try {
@@ -457,7 +459,7 @@ export default function Traces() {
} catch {
// Fall back to the summary view; the row still renders what it has.
}
}, [expandedRow, activeTab])
}, [expandedTraceId, activeTab])
// Auto-refresh every 5 seconds
useEffect(() => {
@@ -470,7 +472,7 @@ export default function Traces() {
if (activeTab === 'api') await tracesApi.clear()
else await tracesApi.clearBackend()
setTraces([])
setExpandedRow(null)
setExpandedTraceId(null)
setDetail(null)
addToast('Traces cleared', 'success')
} catch (err) {
@@ -500,7 +502,7 @@ export default function Traces() {
}
// Reset sort + expansion when switching trace tabs (columns differ).
useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedRow(null); setDetail(null) }, [activeTab])
useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedTraceId(null); setDetail(null) }, [activeTab])
const sortedTraces = sort.key && TRACE_SORT[sort.key]
? [...traces].sort((a, b) => sort.dir === 'asc' ? TRACE_SORT[sort.key](a, b) : TRACE_SORT[sort.key](b, a))
@@ -635,9 +637,9 @@ export default function Traces() {
</thead>
<tbody>
{sortedTraces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => toggleRow(i, trace)} className="clickable">
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'} text-xs`} /></td>
<React.Fragment key={trace.id ?? i}>
<tr onClick={() => toggleRow(trace, i)} className="clickable">
<td><i className={`fas fa-chevron-${expandedTraceId === (trace.id ?? i) ? 'down' : 'right'} text-xs`} /></td>
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
<td className="text-mono text-sm">{trace.request?.path || '-'}</td>
<td className="text-sub cell-clip" title={trace.user_name || trace.user_id || ''}>{trace.user_name || trace.user_id || '-'}</td>
@@ -648,7 +650,7 @@ export default function Traces() {
: <i className="fas fa-check-circle text-success" />}
</td>
</tr>
{expandedRow === i && (
{expandedTraceId === (trace.id ?? i) && (
<tr>
<td colSpan="6" className="p-0">
<ApiTraceDetail trace={detail && detail.id === trace.id ? detail : trace} />
@@ -674,9 +676,9 @@ export default function Traces() {
</thead>
<tbody>
{sortedTraces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => toggleRow(i, trace)} className="clickable">
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'} text-xs`} /></td>
<React.Fragment key={trace.id ?? i}>
<tr onClick={() => toggleRow(trace, i)} className="clickable">
<td><i className={`fas fa-chevron-${expandedTraceId === (trace.id ?? i) ? 'down' : 'right'} text-xs`} /></td>
<td><span style={typeBadgeStyle(trace.type)}>{trace.type || '-'}</span></td>
<td className="text-sub nowrap">{formatDateTime(trace.timestamp)}</td>
<td className="text-mono text-sm">{trace.model_name || '-'}</td>
@@ -690,7 +692,7 @@ export default function Traces() {
: <i className="fas fa-check-circle text-success" />}
</td>
</tr>
{expandedRow === i && (
{expandedTraceId === (trace.id ?? i) && (
<tr>
<td colSpan="7" className="p-0">
<BackendTraceDetail trace={detail && detail.id === trace.id ? detail : trace} />