"use client" import * as React from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { CheckCircle2, ClipboardList, Users, Clock, Zap, Bell, BookOpen, ArrowRight, RotateCw, Info, Check, FileSearch, Workflow } from 'lucide-react' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { Avatar } from '@open-mercato/ui/primitives/avatar' import { Button } from '@open-mercato/ui/primitives/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select' import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@open-mercato/ui/primitives/table' import { EmptyState } from '@open-mercato/ui/primitives/empty-state' 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 { useT, useLocale } from '@open-mercato/shared/lib/i18n/context' import { mapAgent, formatNumber, formatWaitMinutes, mapAgentWindowMetrics, mapOverviewMetrics, mapProposal, mapRun, type OverviewMetricsView, type ProposalView, type RunView, } from '../../components/types' import { OPTIONAL_REQUEST_INIT } from '../../components/optionalRequest' import { subjectLabelOf } from '../../components/subjectRef' import { useCoalescedReload } from '../../components/useCoalescedReload' import { agentAvatarIcon } from '../../components/agentChips' import { SystemHealthTile } from '../../components/SystemHealthTile' import { StartHereGuide } from '../../components/StartHereGuide' import { isAgentPreviewUiEnabled } from '../../lib/featureFlags' type Health = 'good' | 'watch' | 'poor' | 'new' type ListResponse = { items?: Array>; total?: number } type Sla = 'breach' | 'risk' | 'ok' type Verb = 'do' | 'review' type TrustRow = { id: string; label: string; icon: string | null; resultKind: 'research' | 'proposal'; runs: number; overridePct: number | null; status: Health } type StuckRow = { id: string; workflowInstanceId: string | null; claim: string; agentLabel: string; waitingMin: number | null; waitingFor: Verb; sla: Sla } type AgentWindowMetrics = { totalRuns: number; overrideRate: number | null; disposedProposals: number } // `TableHead` ships `whitespace-nowrap`, and a table cell does not clip its // overflow: a label wider than its column is PAINTED ON TOP of the next // header, which is what "Uruchomienia"/"Nadpisania" did in a `table-fixed` // w-16 column. Wrapping is the only rule that holds for a translation nobody // measured yet — `break-words` guarantees even an unbreakable compound // ("Überschreibungen") stays inside the cell, and `hyphens-auto` lets the // page's `lang` dictionary pick a readable break point first. const WRAPPING_HEAD = 'whitespace-normal break-words hyphens-auto' const statusVariant: StatusMap = { good: 'success', watch: 'warning', poor: 'error', new: 'neutral' } const slaVariant: StatusMap = { breach: 'error', risk: 'warning', ok: 'success' } const NEEDS_ATTENTION_PAGE_SIZE = 20 // Rolling windows supported by /metrics/overview and /metrics/agents. type OverviewWindowKey = '24h' | '7d' | '30d' const WINDOW_KEYS: readonly OverviewWindowKey[] = ['24h', '7d', '30d'] as const const WINDOW_LABEL_KEY: Record = { '24h': 'agent_orchestrator.overview.window.h24', '7d': 'agent_orchestrator.overview.window.d7', '30d': 'agent_orchestrator.overview.window.d30', } function windowKeyFrom(raw: string | null): OverviewWindowKey { return (WINDOW_KEYS as readonly string[]).includes(raw ?? '') ? (raw as OverviewWindowKey) : '7d' } // Per-panel fetch outcome: forbidden and error must never masquerade as empty // data — the SLA panel rendering "nothing stuck" on a failed fetch is a false // all-clear. type PanelState = 'ok' | 'forbidden' | 'error' // SLA is derived from how long the proposal has been waiting (real, from created_at). function slaOf(waitingMin: number | null): Sla { if (waitingMin != null && waitingMin > 240) return 'breach' if (waitingMin != null && waitingMin > 120) return 'risk' return 'ok' } function asString(value: unknown): string | null { return typeof value === 'string' ? value : null } function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null } function fieldOf(item: Record, ...keys: string[]): string { for (const key of keys) { const value = asString(item[key]) if (value) return value } return '' } function minutesAgo(value: string | null): number | null { if (!value) return null const parsed = Date.parse(value) if (Number.isNaN(parsed)) return null return Math.max(0, Math.round((Date.now() - parsed) / 60000)) } type ListFetch = | { ok: true; items: Array>; total: number } | { ok: false; status: number } async function fetchList(path: string): Promise { const call = await apiCall(path, OPTIONAL_REQUEST_INIT, { fallback: { items: [] } }) if (!call.ok) return { ok: false, status: call.status } const items = Array.isArray(call.result?.items) ? call.result!.items : [] const total = typeof call.result?.total === 'number' ? call.result!.total : items.length return { ok: true, items, total } } function panelStateOf(res: ListFetch): PanelState { if (res.ok) return 'ok' return res.status === 403 ? 'forbidden' : 'error' } export default function AgentFleetOverviewPage() { const t = useT() const locale = useLocale() const router = useRouter() const searchParams = useSearchParams() const [windowKey, setWindowKey] = React.useState(() => windowKeyFrom(searchParams?.get('window') ?? null)) const [metrics, setMetrics] = React.useState(null) const [pendingProposals, setPendingProposals] = React.useState([]) const [pendingState, setPendingState] = React.useState('ok') const [pendingRuns, setPendingRuns] = React.useState>(new Map()) const [agentLabels, setAgentLabels] = React.useState>(new Map()) const [agentKinds, setAgentKinds] = React.useState>(new Map()) const [agentIcons, setAgentIcons] = React.useState>(new Map()) const [agentIds, setAgentIds] = React.useState([]) const [agentMetrics, setAgentMetrics] = React.useState>(new Map()) const [trustState, setTrustState] = React.useState('ok') const [lastLoadedAt, setLastLoadedAt] = React.useState(null) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState(null) const [refreshKey, setRefreshKey] = React.useState(0) React.useEffect(() => { let cancelled = false async function load() { setIsLoading(true) setError(null) try { const [overviewCall, pendingRes, agentsRes] = await Promise.all([ apiCall>(`/api/agent_orchestrator/metrics/overview?window=${windowKey}`, OPTIONAL_REQUEST_INIT), fetchList( `/api/agent_orchestrator/proposals?disposition=pending&sortField=createdAt&sortDir=asc&pageSize=${NEEDS_ATTENTION_PAGE_SIZE}`, ), fetchList('/api/agent_orchestrator/agents'), ]) if (cancelled) return const overview = overviewCall.ok && overviewCall.result ? mapOverviewMetrics(overviewCall.result) : null if (!overview) { setError(t('agent_orchestrator.overview.error')) return } const pending = pendingRes.ok ? pendingRes.items.map((item) => mapProposal(item)).filter((row): row is ProposalView => !!row) : [] const labels = new Map() const kinds = new Map() const icons = new Map() const ids: string[] = [] if (agentsRes.ok) { for (const item of agentsRes.items) { const agent = mapAgent(item) if (agent) { labels.set(agent.id, agent.label || agent.id) kinds.set(agent.id, agent.resultKind) icons.set(agent.id, agent.icon) ids.push(agent.id) } } } const runIds = Array.from(new Set(pending.map((row) => row.runId))) // Per-agent trust metrics come from ONE batched /metrics/agents call // (chunked at the endpoint's 50-id cap) instead of an N+1 fan-out over // /agents/:id/metrics — same rollup-preferred data, one round-trip. const metricsChunks: string[][] = [] for (let start = 0; start < ids.length; start += 50) metricsChunks.push(ids.slice(start, start + 50)) const [runsRes, batchedMetrics] = await Promise.all([ runIds.length ? fetchList( `/api/agent_orchestrator/runs?ids=${runIds.map((id) => encodeURIComponent(id)).join(',')}&pageSize=${Math.min(runIds.length, 100)}`, ) : Promise.resolve({ ok: true, items: [], total: 0 }), Promise.all( metricsChunks.map((chunk) => apiCall<{ items?: Array> }>( `/api/agent_orchestrator/metrics/agents?window=${windowKey}&ids=${chunk.map((id) => encodeURIComponent(id)).join(',')}`, OPTIONAL_REQUEST_INIT, { fallback: { items: [] } }, ), ), ), ]) if (cancelled) return // Run enrichment is cosmetic (claim labels) — its failure degrades to // run-id prefixes and never flips a panel into an error state. const runs = new Map() if (runsRes.ok) { for (const item of runsRes.items) { const run = mapRun(item) if (run) runs.set(run.id, run) } } const perAgent = new Map() let metricsFailure: PanelState = 'ok' for (const call of batchedMetrics) { if (!call.ok) { metricsFailure = call.status === 403 ? 'forbidden' : metricsFailure === 'forbidden' ? 'forbidden' : 'error' continue } if (!Array.isArray(call.result?.items)) continue for (const item of call.result.items) { const mapped = mapAgentWindowMetrics(item as Record) if (!mapped) continue perAgent.set(mapped.agentId, { totalRuns: mapped.runsTotal, overrideRate: mapped.overrideRate, disposedProposals: mapped.disposedProposals, }) } } setMetrics(overview) setPendingProposals(pending) setPendingState(panelStateOf(pendingRes)) setPendingRuns(runs) setAgentLabels(labels) setAgentKinds(kinds) setAgentIcons(icons) setAgentIds(ids) setAgentMetrics(perAgent) // The trust panel needs both the registry and its metrics; surface the // stronger signal (forbidden beats error) when either fails. setTrustState(!agentsRes.ok ? panelStateOf(agentsRes) : metricsFailure) setLastLoadedAt(Date.now()) } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : t('agent_orchestrator.overview.error')) } finally { if (!cancelled) setIsLoading(false) } } load() return () => { cancelled = true } }, [t, refreshKey, windowKey]) // Live-refresh KPIs + needs-attention queue on any proposal lifecycle change // (DOM Event Bridge, tenant/org-scoped server-side), coalesced so an event // burst triggers at most one reload per interval. const triggerReload = React.useCallback(() => setRefreshKey((key) => key + 1), []) const coalescedReload = useCoalescedReload(triggerReload) useAppEvent('agent_orchestrator.proposal.*', () => { coalescedReload() }) const changeWindow = React.useCallback( (value: string) => { const next = windowKeyFrom(value) setWindowKey(next) router.replace(`/backend/overview?window=${next}`, { scroll: false }) }, [router], ) // Re-render every 30s so the "refreshed X ago" text stays honest without a reload. const [, setClockTick] = React.useState(0) React.useEffect(() => { const id = setInterval(() => setClockTick((value) => value + 1), 30_000) return () => clearInterval(id) }, []) const refreshedMin = lastLoadedAt == null ? null : Math.floor((Date.now() - lastLoadedAt) / 60_000) const windowLabel = t(WINDOW_LABEL_KEY[windowKey]) const kpi = React.useMemo(() => { if (!metrics) return { autoPct: null as number | null, pendingCount: 0, oldestMin: null as number | null } return { autoPct: metrics.autoApproveRate == null ? null : Math.round(metrics.autoApproveRate * 100), pendingCount: metrics.pendingCount, oldestMin: minutesAgo(metrics.oldestPendingAt), } }, [metrics]) const trust = React.useMemo(() => { // Backed by GET /agents/:id/metrics (rollup-preferred, live fallback) so // large fleets read stable windows instead of a capped 100-row sample. return agentIds .map((id) => { const stats = agentMetrics.get(id) ?? null const runsCount = stats?.totalRuns ?? 0 const overridePct = stats && stats.overrideRate != null ? Math.round(stats.overrideRate * 100) : null let status: Health = 'new' if (runsCount > 0 || (stats?.disposedProposals ?? 0) > 0) { if ((overridePct ?? 0) > 30) status = 'poor' else if ((overridePct ?? 0) > 15) status = 'watch' else status = 'good' } const resultKind: 'research' | 'proposal' = agentKinds.get(id) === 'proposal' ? 'proposal' : 'research' return { id, label: agentLabels.get(id) || id, icon: agentIcons.get(id) ?? null, resultKind, runs: runsCount, overridePct, status } }) .sort((a, b) => b.runs - a.runs) }, [agentIds, agentMetrics, agentLabels, agentIcons, agentKinds]) const stuck = React.useMemo(() => { return pendingProposals .map((proposal) => { const run = pendingRuns.get(proposal.runId) ?? null const input = run ? asObject(run.input) : null const claim = subjectLabelOf(input, proposal.runId) const waitingMin = minutesAgo(proposal.createdAt) return { id: proposal.id, workflowInstanceId: proposal.workflowInstanceId, claim, agentLabel: agentLabels.get(proposal.agentId) || proposal.agentId || '—', waitingMin, waitingFor: (agentKinds.get(proposal.agentId) === 'proposal' ? 'do' : 'review') as Verb, sla: slaOf(waitingMin), } }) .sort((a, b) => (b.waitingMin ?? 0) - (a.waitingMin ?? 0)) .slice(0, 6) }, [pendingProposals, pendingRuns, agentLabels, agentKinds]) const previewUi = isAgentPreviewUiEnabled() const backendChip = // Per-verb intervention counts need a backend taxonomy (Review/Question/Do/Notify/Know). // Until then these are representative demo figures so the section reads like the design. const interventions = [ { key: 'review', icon: Check, count: 412, pct: 41 }, { key: 'question', icon: FileSearch, count: 188, pct: 18 }, { key: 'do', icon: Zap, count: 96, pct: 10 }, { key: 'notify', icon: Bell, count: 61, pct: 6 }, { key: 'know', icon: BookOpen, count: 53, pct: 5 }, ] as const const dispositionTotal = metrics ? Object.values(metrics.dispositionCounts).reduce((sum, count) => sum + count, 0) : 0 const empty = !isLoading && !error && (!metrics || (metrics.runsTotal === 0 && dispositionTotal === 0)) return (

{t('agent_orchestrator.overview.title', 'Fleet overview')}

{t('agent_orchestrator.overview.processesHandled', '{count} processes handled', { count: formatNumber(metrics?.runsTotal ?? 0, locale) ?? '0' })} · {windowLabel}
{metrics?.source === 'live' ? ( {t('agent_orchestrator.overview.liveSource', 'Live figures — rollups not computed yet')} ) : null} {refreshedMin != null ? ( {refreshedMin < 1 ? t('agent_orchestrator.overview.refreshedJustNow', 'Updated just now') : t('agent_orchestrator.overview.refreshedAt', 'Updated {time} ago', { time: formatWaitMinutes(refreshedMin) ?? '—' })} ) : null}
{isLoading ? ( ) : error ? ( ) : empty ? ( // "Is anything down?" does not depend on this tenant having agent // activity — and is most worth asking when the counts are empty, so // the health tile renders on this side of the gate too. <>
) : ( <>
: `${kpi.autoPct}%`} sub={t('agent_orchestrator.overview.kpi.autoCompletedSub', 'Cleared with no human touch')} /> {t('agent_orchestrator.overview.kpi.oldest', 'oldest {time}', { time: formatWaitMinutes(kpi.oldestMin) ?? '—' })}} sub={t('agent_orchestrator.overview.kpi.needsDecisionSub', 'Waiting in the inbox now')} /> {previewUi ? ( <> ) : null}
router.push('/backend/caseload')}> {pendingState !== 'ok' ? ( ) : stuck.length === 0 ? (

{t('agent_orchestrator.overview.stuck.empty', 'Nothing stuck right now')}

) : ( // Six nowrap headers can out-measure a 3/5 panel; without a // scroll container the Panel's `overflow-hidden` silently ate // the SLA column. Wrapping headers shrink the table's minimum // width first, the scroller is the honest fallback.
{t('agent_orchestrator.overview.stuck.col.id', 'ID')} {t('agent_orchestrator.overview.stuck.col.process', 'Process')} {t('agent_orchestrator.overview.stuck.col.agent', 'Agent')} {t('agent_orchestrator.overview.stuck.col.waitingFor', 'Waiting for')} {t('agent_orchestrator.overview.stuck.col.waitingTime', 'Waiting time')} {t('agent_orchestrator.overview.stuck.col.sla', 'SLA')} {stuck.map((row) => ( router.push(`/backend/caseload/${encodeURIComponent(row.id)}`)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() router.push(`/backend/caseload/${encodeURIComponent(row.id)}`) } }} > {row.claim} {row.workflowInstanceId ? ( ) : ( — )} {row.agentLabel} {t(`agent_orchestrator.overview.interventions.${row.waitingFor}`, titleCase(row.waitingFor))} {formatWaitMinutes(row.waitingMin) ?? '—'} {t(`agent_orchestrator.overview.stuck.sla.${row.sla}`, titleCase(row.sla))} ))}
)}
router.push('/backend/agents')}> {trustState !== 'ok' ? ( ) : trust.length === 0 ? (

{t('agent_orchestrator.overview.trust.empty', 'No agents yet')}

) : ( // `table-fixed` is what makes the declared widths hold and lets the // agent column absorb the remainder. Under the default auto layout the // table sized itself to its longest agent id — 620px inside a 449px // panel — and the Panel's `overflow-hidden` cut the Status column off // with no scrollbar to reveal it. The `truncate` below was already // written for this and never engaged. "Absorb the remainder" still // needs a floor: below it the Agent column loses its header to // letter-by-letter wrapping while Runs/Status hold their own w-32. A // `min-w-*` on the `` itself does NOT work — the fixed-layout // column-sizing algorithm only reads explicit `width` on first-row // cells and ignores `min-width` there. `min-w-96` on the `` // (its own box, not a cell) sums the three columns' floors (w-32 x3) // so the `overflow-x-auto` wrapper above is what yields, not the column.
{t('agent_orchestrator.overview.trust.col.agent', 'Agent')} {/* Widths hold the column's CONTENT (a count, a meter, a badge) and the header wraps to whatever the locale needs — a header sized to the longest translation would spend the agent name's space on whitespace in every other language. */} {t('agent_orchestrator.overview.trust.col.runs', 'Runs')} {/* The panel is 2/5 of the row, so four columns leave the agent name ~59px to live in below 2xl. Override is the least identifying of them — a meter and a percentage — so it is the one that yields the space back to the name. */} {t('agent_orchestrator.overview.trust.col.status', 'Status')} {trust.map((row) => ( router.push(`/backend/agents/${encodeURIComponent(row.id)}`)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() router.push(`/backend/agents/${encodeURIComponent(row.id)}`) } }} >
{/* Truncation without a title destroys the id with no way to read it back; the column is narrow by design here. */}
{row.label}
{row.id}
{formatNumber(row.runs, locale) ?? '0'} {t(`agent_orchestrator.agents.list.status.${row.status}`, row.status)}
))}
)} {previewUi ? (
{t('agent_orchestrator.overview.interventions.title', 'Where humans stepped in')}
{t('agent_orchestrator.common.sample', 'Sample')}
{interventions.map(({ key, icon: Icon, count, pct }) => (
{t(`agent_orchestrator.overview.interventions.${key}`, titleCase(key))}
{t(`agent_orchestrator.overview.interventions.${key}Sub`, '')}
{formatNumber(count, locale) ?? '0'}

{pct}% {t('agent_orchestrator.overview.interventions.ofTotal', 'of all interventions')}

))}
) : null} )} ) } function titleCase(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1) } function KpiTile({ icon: Icon, label, caption, value, chip, sub }: { icon: React.ComponentType<{ className?: string }>; label: string; caption?: string; value: React.ReactNode; chip?: React.ReactNode; sub: string }) { return (

{label} {caption ? · {caption} : null}

{value} {chip}

{sub}

) } function ContextChip({ children }: { children: React.ReactNode }) { return ( {children} ) } function OldestChip({ children }: { children: React.ReactNode }) { return ( {children} ) } function Panel({ title, caption, viewAll, onViewAll, children, className }: { title: string; caption?: string; viewAll: string; onViewAll: () => void; children: React.ReactNode; className?: string }) { return (
{title} {caption ? · {caption} : null}
{children}
) } /** * Honest panel failure states: a 403 must read as "no access", a fetch failure * as an error with retry — never as an empty (all-clear) dataset. */ function PanelNote({ state, onRetry }: { state: Exclude; onRetry: () => void }) { const t = useT() if (state === 'forbidden') { return (

{t('agent_orchestrator.overview.panel.forbidden', "You don't have access to this data.")}

) } return (

{t('agent_orchestrator.overview.panel.error', "Couldn't load this panel.")}

) } function PendingChip({ label }: { label: string }) { return ( {label} ) } // Segmented "barcode" meter: filled ticks sample the OM brand gradient (lime -> yellow -> // violet) so higher override shifts toward yellow; remaining ticks are muted. function OverrideMeter({ pct }: { pct: number | null }) { if (pct == null) return — const total = 16 const step = 3 const filled = pct === 0 ? 0 : Math.max(1, Math.min(total, Math.round((pct / 100) * total))) return (
{pct}%
{Array.from({ length: total }).map((_, index) => (
))}
) }