mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
The Operate → Traces "API Traces" panel already recorded who made each request (user_id/user_name) but never showed it, and did not capture the caller's network identity at all. Operators asked to see the requesting user (#10886) and the client IP + user agent (#10887) so a trace can be attributed to who/what issued it. Backend: add ClientIP and UserAgent to APIExchange and populate them from echo's c.RealIP() (honours X-Forwarded-For / X-Real-IP behind a trusted proxy) and the request's User-Agent header. Both are omitempty and the /api/traces swagger response is map[string]any, so this is additive. UI: add a sortable "User" column to the API traces table and a metadata block (User / Client IP / User Agent) at the top of the expanded row detail. Fields render only when present, so older buffered traces and unauthenticated/local requests degrade cleanly. Adds an e2e spec covering the new column value and the expanded metadata. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
bf484c5181
commit
4be6e22b5f
@@ -43,6 +43,12 @@ type APIExchange struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
// ClientIP is the caller's address as resolved by echo (honours
|
||||
// X-Forwarded-For / X-Real-IP behind a trusted proxy), and UserAgent
|
||||
// is the raw User-Agent header. Both are surfaced in the admin Traces
|
||||
// UI so an operator can tell who/what issued each request.
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
var traceBuffer *circularbuffer.Queue[APIExchange]
|
||||
@@ -221,6 +227,8 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
exchange := APIExchange{
|
||||
Timestamp: startTime,
|
||||
Duration: time.Since(startTime),
|
||||
ClientIP: c.RealIP(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
Request: APIExchangeRequest{
|
||||
Method: c.Request().Method,
|
||||
Path: c.Path(),
|
||||
|
||||
44
core/http/react-ui/e2e/traces-metadata.spec.js
Normal file
44
core/http/react-ui/e2e/traces-metadata.spec.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Pin the API-trace metadata surface (issues #10886 / #10887): the API
|
||||
// traces table exposes the requesting user in a dedicated column, and the
|
||||
// expanded row detail lists User, Client IP and User Agent so an operator
|
||||
// can tell who/what issued each request.
|
||||
test.describe('Traces - API request metadata', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/traces', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
request: { method: 'POST', path: '/v1/chat/completions' },
|
||||
response: { status: 200 },
|
||||
user_id: 'user-123',
|
||||
user_name: 'alice',
|
||||
client_ip: '203.0.113.7',
|
||||
user_agent: 'curl/8.4.0',
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/backend-traces', (route) => {
|
||||
route.fulfill({ contentType: 'application/json', body: '[]' })
|
||||
})
|
||||
await page.goto('/app/traces')
|
||||
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('shows the User column value in the API traces table', async ({ page }) => {
|
||||
await expect(page.locator('th', { hasText: 'User' })).toBeVisible()
|
||||
await expect(page.locator('td', { hasText: 'alice' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('expands the row to reveal Client IP and User Agent', async ({ page }) => {
|
||||
await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
|
||||
|
||||
await expect(page.locator('text=Client IP').first()).toBeVisible()
|
||||
await expect(page.locator('text=203.0.113.7').first()).toBeVisible()
|
||||
await expect(page.locator('text=User Agent').first()).toBeVisible()
|
||||
await expect(page.locator('text=curl/8.4.0').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -304,8 +304,27 @@ function BackendTraceDetail({ trace }) {
|
||||
|
||||
// Expanded detail for an API trace row
|
||||
function ApiTraceDetail({ trace }) {
|
||||
const user = trace.user_name || trace.user_id
|
||||
const meta = [
|
||||
['User', user],
|
||||
['Client IP', trace.client_ip],
|
||||
['User Agent', trace.user_agent],
|
||||
].filter(([, v]) => v)
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-md)', background: 'var(--color-bg-secondary)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{meta.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.25rem var(--spacing-md)',
|
||||
alignItems: 'baseline', marginBottom: 'var(--spacing-md)', fontSize: '0.8125rem',
|
||||
}}>
|
||||
{meta.map(([label, value]) => (
|
||||
<React.Fragment key={label}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-text-secondary)' }}>{label}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>{value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{trace.error && (
|
||||
<div style={{
|
||||
background: 'var(--color-error-light)', border: '1px solid var(--color-error-border)',
|
||||
@@ -360,6 +379,7 @@ export default function Traces() {
|
||||
const TRACE_SORT = {
|
||||
method: (a, b) => (a.request?.method || '').localeCompare(b.request?.method || ''),
|
||||
path: (a, b) => (a.request?.path || '').localeCompare(b.request?.path || ''),
|
||||
user: (a, b) => (a.user_name || a.user_id || '').localeCompare(b.user_name || b.user_id || ''),
|
||||
status: (a, b) => (a.response?.status || 0) - (b.response?.status || 0),
|
||||
type: (a, b) => (a.type || '').localeCompare(b.type || ''),
|
||||
time: (a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0),
|
||||
@@ -615,6 +635,7 @@ export default function Traces() {
|
||||
<th style={{ width: '30px' }}></th>
|
||||
{sortableTh('method', 'Method')}
|
||||
{sortableTh('path', 'Path')}
|
||||
{sortableTh('user', 'User')}
|
||||
{sortableTh('status', 'Status')}
|
||||
<th style={{ width: '40px' }}>Result</th>
|
||||
</tr>
|
||||
@@ -626,6 +647,7 @@ export default function Traces() {
|
||||
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
|
||||
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
|
||||
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={trace.user_name || trace.user_id || ''}>{trace.user_name || trace.user_id || '-'}</td>
|
||||
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{trace.error
|
||||
@@ -635,7 +657,7 @@ export default function Traces() {
|
||||
</tr>
|
||||
{expandedRow === i && (
|
||||
<tr>
|
||||
<td colSpan="5" style={{ padding: 0 }}>
|
||||
<td colSpan="6" style={{ padding: 0 }}>
|
||||
<ApiTraceDetail trace={trace} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user