"use client" import * as React from 'react' import { useRouter } from 'next/navigation' import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy' import type { SortingState } from '@tanstack/react-table' import { RotateCw, Smile, Meh, Frown, Clock, ArrowUpDown, ChevronDown, CheckCircle2, Gauge, TriangleAlert } from 'lucide-react' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { Button } from '@open-mercato/ui/primitives/button' import { Avatar } from '@open-mercato/ui/primitives/avatar' import { agentAvatarIcon } from '../../components/agentChips' import { useAgentIconMap } from '../../components/useAgentIcons' import { StatusBadge } from '@open-mercato/ui/primitives/status-badge' import { EmptyState } from '@open-mercato/ui/primitives/empty-state' import { SegmentedControl, SegmentedControlItem } from '@open-mercato/ui/primitives/segmented-control' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select' import { SearchInput } from '@open-mercato/ui/primitives/search-input' import { Checkbox } from '@open-mercato/ui/primitives/checkbox' import { Popover, PopoverContent, PopoverTrigger } from '@open-mercato/ui/primitives/popover' import { DataTable } from '@open-mercato/ui/backend/DataTable' import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { cn } from '@open-mercato/shared/lib/utils' import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context' import { useCoalescedReload } from '../../components/useCoalescedReload' import { mapRun, mapOverviewMetrics, mapAgent, formatCostMinor, formatDateTime, formatDurationMs, formatRelativeAge, type RunView, type TracesKpiView } from '../../components/types' import { runStatusLabelKey } from '../../components/cockpitStatus' import { TRACES_DEFAULT_SORT, TRACES_HEADER_SORT_FIELDS, serverSortToSorting, sortingToServerSort, type ServerSort, } from '../../components/serverSort' import { normalizeRunIdPrefix } from '../../data/validators' type RunsResponse = { items?: Array>; total?: number; totalPages?: number; totalIsCapped?: boolean } type WindowKey = '24h' | '7d' | '30d' type FacetKey = 'all' | 'errors' | 'needs-review' type SortKey = 'recentDesc' | 'recentAsc' | 'latencyDesc' | 'confidenceDesc' | 'confidenceAsc' | 'agentAsc' const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [ { key: 'recentDesc', labelKey: 'agent_orchestrator.traces.sort.recentDesc' }, { key: 'recentAsc', labelKey: 'agent_orchestrator.traces.sort.recentAsc' }, { key: 'latencyDesc', labelKey: 'agent_orchestrator.traces.sort.latencyDesc' }, { key: 'confidenceDesc', labelKey: 'agent_orchestrator.traces.sort.confidenceDesc' }, { key: 'confidenceAsc', labelKey: 'agent_orchestrator.traces.sort.confidenceAsc' }, { key: 'agentAsc', labelKey: 'agent_orchestrator.traces.sort.agentAsc' }, ] /** SortKey → server sortField/sortDir (keys of the runs route's sortFieldMap). */ const SORT_PARAMS: Record = { recentDesc: { sortField: 'createdAt', sortDir: 'desc' }, recentAsc: { sortField: 'createdAt', sortDir: 'asc' }, latencyDesc: { sortField: 'latencyMs', sortDir: 'desc' }, confidenceDesc: { sortField: 'confidence', sortDir: 'desc' }, confidenceAsc: { sortField: 'confidence', sortDir: 'asc' }, agentAsc: { sortField: 'agentId', sortDir: 'asc' }, } /** Facet → server list params (matches the runs route's filter facets). */ function facetParams(facet: FacetKey): Record { if (facet === 'errors') return { status: 'error' } if (facet === 'needs-review') return { filter: 'needs-review' } return {} } // Run status: ok is the overwhelming majority, so it stays neutral (no green) — // the Eval column owns the health signal (green pass / red fail). Errors and // in-flight runs keep their status colour so the eye catches the anomalies. const TRACE_STATUS_VARIANT: Record = { running: 'info', ok: 'neutral', error: 'error', } function confidencePctOf(confidence: number | null): number | null { if (confidence == null) return null return confidence <= 1 ? confidence * 100 : confidence } // Confidence reads faster as a face than a bare number — same scale as caseload. function confidenceFace(pct: number): { Icon: React.ComponentType<{ className?: string }>; color: string } { if (pct >= 70) return { Icon: Smile, color: 'text-status-success-text' } if (pct >= 40) return { Icon: Meh, color: 'text-muted-foreground' } return { Icon: Frown, color: 'text-status-error-text' } } function ranAtValue(createdAt: string | null): number { if (!createdAt) return 0 const parsed = Date.parse(createdAt) return Number.isNaN(parsed) ? 0 : parsed } // Quick filter over the loaded page. Run-id prefixes additionally search the // FULL history server-side (`idPrefix`, debounced below) — this client pass // only keeps the interim rows consistent while the debounce settles. function matchesSearch(run: RunView, query: string): boolean { const q = query.trim().toLowerCase() if (!q) return true const hex = normalizeRunIdPrefix(q) if (hex && run.id.replace(/-/g, '').toLowerCase().startsWith(hex)) return true return [run.agentId, run.model, run.runtime, run.externalRunId] .some((field) => typeof field === 'string' && field.toLowerCase().includes(q)) } export default function AgentTracesPage() { const t = useT() const locale = useLocale() const router = useRouter() const [runs, setRuns] = React.useState([]) const [total, setTotal] = React.useState(0) const [totalIsCapped, setTotalIsCapped] = React.useState(false) const [counts, setCounts] = React.useState<{ all: number; errors: number; needsReview: number }>({ all: 0, errors: 0, needsReview: 0 }) const [kpis, setKpis] = React.useState(null) const [kpisForbidden, setKpisForbidden] = React.useState(false) const [agentOptions, setAgentOptions] = React.useState([]) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState(null) const [window, setWindow] = React.useState('7d') const [facet, setFacet] = React.useState('all') const [search, setSearch] = React.useState('') // Debounced run-id prefix pushed down to the server (full-history search). const [idPrefix, setIdPrefix] = React.useState(null) const [agentFilters, setAgentFilters] = React.useState([]) // One canonical sort state — the toolbar Select and the column headers both // read and write it, so the two controls can never diverge. const [sort, setSort] = React.useState(TRACES_DEFAULT_SORT) const [page, setPage] = React.useState(1) const [pageSize, setPageSize] = React.useState(20) const [reloadToken, setReloadToken] = React.useState(0) const agentFilter = agentFilters[0] ?? null // 300 ms debounce before the id-prefix search hits the server; non-id text // stays a client-side quick filter over the loaded page. React.useEffect(() => { const handle = setTimeout(() => { setIdPrefix(normalizeRunIdPrefix(search) ? search.trim() : null) }, 300) return () => clearTimeout(handle) }, [search]) React.useEffect(() => { let cancelled = false async function load() { setIsLoading(true) setError(null) const scoped: Record = { window } if (agentFilter) scoped.agentId = agentFilter if (idPrefix) scoped.idPrefix = idPrefix const listParams = new URLSearchParams({ ...scoped, ...facetParams(facet), page: String(page), pageSize: String(pageSize), sortField: sort.field, sortDir: sort.dir, }) const countProbe = (probeFacet: FacetKey) => apiCall( `/api/agent_orchestrator/runs?${new URLSearchParams({ ...scoped, ...facetParams(probeFacet), page: '1', pageSize: '1' }).toString()}`, undefined, { fallback: { items: [], total: 0 } }, ) const [listCall, allProbe, errorsProbe, reviewProbe, kpiCall, agentsCall] = await Promise.all([ apiCall(`/api/agent_orchestrator/runs?${listParams.toString()}`, undefined, { fallback: { items: [], total: 0 }, }), countProbe('all'), countProbe('errors'), countProbe('needs-review'), apiCall>(`/api/agent_orchestrator/metrics/overview?window=${window}`), apiCall<{ items?: Array> }>('/api/agent_orchestrator/agents', undefined, { fallback: { items: [] }, }), ]) if (cancelled) return if (!listCall.ok) { setError(t('agent_orchestrator.traces.error')) setIsLoading(false) return } const items = Array.isArray(listCall.result?.items) ? listCall.result!.items : [] setRuns(items.map((item) => mapRun(item as Record)).filter((row): row is RunView => !!row)) setTotal(typeof listCall.result?.total === 'number' ? listCall.result.total : items.length) setTotalIsCapped(listCall.result?.totalIsCapped === true) setCounts({ all: (allProbe.ok && allProbe.result?.total) || 0, errors: (errorsProbe.ok && errorsProbe.result?.total) || 0, needsReview: (reviewProbe.ok && reviewProbe.result?.total) || 0, }) // The KPI strip reads /metrics/overview (gate proposals.view) while this // page gates trace.view — a forbidden strip renders an inline note, never // fake zeros (data-honesty pass). if (kpiCall.ok && kpiCall.result) { const overview = mapOverviewMetrics(kpiCall.result) setKpis(overview?.traces ?? null) setKpisForbidden(false) } else { setKpis(null) setKpisForbidden(kpiCall.status === 403) } // The agent filter needs the registry (gate agents.view) — hidden when // the viewer cannot list agents. const agents = agentsCall.ok && Array.isArray(agentsCall.result?.items) ? agentsCall.result.items : [] setAgentOptions( agents .map((item) => mapAgent(item)?.id) .filter((id): id is string => !!id) .sort((a, b) => a.localeCompare(b)), ) setIsLoading(false) } void load() return () => { cancelled = true } }, [t, window, facet, agentFilter, idPrefix, sort, page, pageSize, reloadToken]) // Live refresh: run completions and trace ingests refetch the current page // (DOM Event Bridge, org-scoped server-side), coalesced so an event burst // triggers at most one refetch per interval. The manual refresh button stays. const reload = React.useCallback(() => setReloadToken((token) => token + 1), []) const coalescedReload = useCoalescedReload(reload) useAppEvent('agent_orchestrator.run.completed', () => { coalescedReload() }) useAppEvent('agent_orchestrator.run.ingested', () => { coalescedReload() }) // Page-scoped quick filter (server rows for the current page only). const pagedRows = React.useMemo( () => (search.trim() ? runs.filter((run) => matchesSearch(run, search)) : runs), [runs, search], ) const totalPages = Math.max(1, Math.ceil(total / pageSize)) React.useEffect(() => { setPage(1) }, [window, facet, agentFilter, idPrefix, sort]) // Toolbar Select ↔ header sort sync: derive the Select value from the // canonical sort; header-only sorts (e.g. status) show the custom placeholder. const activeSortKey = React.useMemo( () => (Object.keys(SORT_PARAMS) as SortKey[]).find( (key) => SORT_PARAMS[key].sortField === sort.field && SORT_PARAMS[key].sortDir === sort.dir, ) ?? null, [sort], ) const tableSorting = React.useMemo( () => serverSortToSorting(sort, TRACES_HEADER_SORT_FIELDS), [sort], ) const handleSortingChange = React.useCallback((next: SortingState) => { setSort(sortingToServerSort(next, TRACES_HEADER_SORT_FIELDS) ?? TRACES_DEFAULT_SORT) }, []) const agentIcons = useAgentIconMap() const columns = React.useMemo[]>(() => [ { id: 'agent', accessorKey: 'agentId', header: t('agent_orchestrator.traces.col.agent', 'Agent'), meta: { maxWidth: '240px' }, cell: ({ row }) => { const info = agentIcons.get(row.original.agentId) return (
{row.original.agentId}
) }, }, { id: 'when', accessorFn: (row) => ranAtValue(row.createdAt), header: t('agent_orchestrator.traces.col.when', 'When'), cell: ({ row }) => , }, { id: 'eval', accessorFn: (row) => (row.evalPassed === true ? 2 : row.evalPassed === false ? 0 : 1), // Not header-sortable: the badge shows eval_passed while the server's only // eval sort key is eval_score — a header sort would advertise an ordering // the column doesn't display. enableSorting: false, header: t('agent_orchestrator.traces.col.eval', 'Eval'), cell: ({ row }) => { if (row.original.evalPassed === true) return {t('agent_orchestrator.traces.eval.pass')} if (row.original.evalPassed === false) return {t('agent_orchestrator.traces.eval.fail')} return — }, }, { id: 'confidence', accessorKey: 'confidence', header: t('agent_orchestrator.traces.col.confidence', 'Confidence'), cell: ({ row }) => { const pct = confidencePctOf(row.original.confidence) if (pct == null) return — const { Icon, color } = confidenceFace(pct) return (
{Math.round(pct)}%
) }, }, { id: 'latency', accessorKey: 'latencyMs', header: t('agent_orchestrator.traces.col.latency', 'Latency'), cell: ({ row }) => { const value = formatDurationMs(row.original.latencyMs) return value ? {value} : — }, }, { id: 'cost', accessorKey: 'costMinor', header: t('agent_orchestrator.traces.col.cost', 'Cost'), cell: ({ row }) => { const value = formatCostMinor(row.original.costMinor, row.original.currency) return value ? {value} : — }, }, { id: 'status', accessorKey: 'status', header: t('agent_orchestrator.traces.col.status', 'Status'), cell: ({ row }) => ( {t(runStatusLabelKey(row.original.status))} ), }, ], [t, locale, agentIcons]) // Empty only when the whole window is empty (not just a filtered facet/page // or an active run-id search — those get the table's own empty state). const showEmpty = !isLoading && !error && counts.all === 0 && facet === 'all' && !agentFilter && !idPrefix return (

{t('agent_orchestrator.traces.title')}

{t('agent_orchestrator.traces.subtitle')}

{isLoading ? ( ) : error ? ( ) : showEmpty ? ( ) : ( <>
setWindow(value as WindowKey)}> {t('agent_orchestrator.traces.window.24h')} {t('agent_orchestrator.traces.window.7d')} {t('agent_orchestrator.traces.window.30d')}
{agentOptions.length > 0 ? ( setAgentFilters(next.length > 0 ? [next[next.length - 1]] : [])} /> ) : null}
columns={columns} data={pagedRows} sortable manualSorting sorting={tableSorting} onSortingChange={handleSortingChange} columnChooser={{ auto: true }} onRowClick={(row) => router.push(`/backend/traces/${row.id}`)} pagination={{ page, pageSize, total, totalPages, totalIsCapped, onPageChange: setPage, pageSizeOptions: [10, 20, 50], onPageSizeChange: (next) => { setPageSize(next); setPage(1) }, }} /> )}
) } // "7d" alone reads as a mystery number — the clock icon + the exact timestamp in // the tooltip make it unmistakably "when this run happened". function WhenLabel({ createdAt, locale }: { createdAt: string | null; locale: string }) { return ( {formatRelativeAge(createdAt) ?? '—'} ) } // Window-scoped observability summary, served by /metrics/overview's `traces` // block (rollup-preferred, live fallback) — real window aggregates, never a // sample of the loaded page. Mirrors the Overview KpiTile recipe so the cockpit // reads as one product. The endpoint gates `proposals.view` while this page // gates `trace.view`: a forbidden fetch renders an inline note, not fake zeros. function KpiStrip({ kpis, forbidden }: { kpis: TracesKpiView | null; forbidden: boolean }) { const t = useT() if (forbidden || !kpis) { return (
{forbidden ? t('agent_orchestrator.traces.kpi.forbidden') : t('agent_orchestrator.traces.kpi.unavailable')}
) } const passPct = kpis.evalPassRate == null ? null : Math.round(kpis.evalPassRate * 100) const errorPct = kpis.errorRate == null ? null : Math.round(kpis.errorRate * 100) const tiles = [ { icon: CheckCircle2, label: t('agent_orchestrator.traces.kpi.passRate'), value: passPct == null ? '—' : `${passPct}%`, sub: t('agent_orchestrator.traces.kpi.passRateSub') }, { icon: Gauge, label: t('agent_orchestrator.traces.kpi.p95Latency'), value: formatDurationMs(kpis.p95LatencyMs) ?? '—', sub: t('agent_orchestrator.traces.kpi.p95LatencySub') }, { icon: TriangleAlert, label: t('agent_orchestrator.traces.kpi.errorRate'), value: errorPct == null ? '—' : `${errorPct}%`, sub: t('agent_orchestrator.traces.kpi.errorRateSub') }, ] return (
{tiles.map(({ icon: Icon, label, value, sub }) => (

{label}

{value}

{sub}

))}
{kpis.source === 'live' ? (

{t('agent_orchestrator.traces.kpi.sourceLive')}

) : null}
) } function FacetTabs({ facet, counts, total, onFacetChange, }: { facet: FacetKey counts: { errors: number; needsReview: number } total: number onFacetChange: (facet: FacetKey) => void }) { const t = useT() const tabs: Array<{ key: FacetKey; label: string; count: number }> = [ { key: 'all', label: t('agent_orchestrator.traces.facet.all'), count: total }, { key: 'errors', label: t('agent_orchestrator.traces.facet.errors'), count: counts.errors }, { key: 'needs-review', label: t('agent_orchestrator.traces.facet.needsReview'), count: counts.needsReview }, ] return (
{tabs.map((tab) => { const active = facet === tab.key return ( ) })}
) } function MultiSelectPill({ allLabel, options, selected, onChange, }: { allLabel: string options: string[] selected: string[] onChange: (next: string[]) => void }) { const t = useT() const label = selected.length === 0 ? allLabel : selected.length === 1 ? selected[0] : t('agent_orchestrator.traces.filter.selected', undefined, { count: selected.length }) const toggle = (value: string) => onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]) return (
{options.length === 0 ? (

—

) : ( options.map((value) => ( )) )}
) }