@nextsparkjs/plugin-langchain 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.env.example +41 -0
  2. package/api/observability/metrics/route.ts +110 -0
  3. package/api/observability/traces/[traceId]/route.ts +398 -0
  4. package/api/observability/traces/route.ts +205 -0
  5. package/api/sessions/route.ts +332 -0
  6. package/components/observability/CollapsibleJson.tsx +71 -0
  7. package/components/observability/CompactTimeline.tsx +75 -0
  8. package/components/observability/ConversationFlow.tsx +271 -0
  9. package/components/observability/DisabledMessage.tsx +21 -0
  10. package/components/observability/FiltersPanel.tsx +82 -0
  11. package/components/observability/ObservabilityDashboard.tsx +230 -0
  12. package/components/observability/SpansList.tsx +210 -0
  13. package/components/observability/TraceDetail.tsx +335 -0
  14. package/components/observability/TraceStatusBadge.tsx +39 -0
  15. package/components/observability/TracesTable.tsx +97 -0
  16. package/components/observability/index.ts +7 -0
  17. package/docs/01-getting-started/01-overview.md +196 -0
  18. package/docs/01-getting-started/02-installation.md +368 -0
  19. package/docs/01-getting-started/03-configuration.md +794 -0
  20. package/docs/02-core-concepts/01-architecture.md +566 -0
  21. package/docs/02-core-concepts/02-agents.md +597 -0
  22. package/docs/02-core-concepts/03-tools.md +689 -0
  23. package/docs/03-orchestration/01-graph-orchestrator.md +809 -0
  24. package/docs/03-orchestration/02-legacy-react.md +650 -0
  25. package/docs/04-advanced/01-observability.md +645 -0
  26. package/docs/04-advanced/02-token-tracking.md +469 -0
  27. package/docs/04-advanced/03-streaming.md +476 -0
  28. package/docs/04-advanced/04-guardrails.md +597 -0
  29. package/docs/05-reference/01-api-reference.md +1403 -0
  30. package/docs/05-reference/02-customization.md +646 -0
  31. package/docs/05-reference/03-examples.md +881 -0
  32. package/docs/index.md +85 -0
  33. package/hooks/observability/useMetrics.ts +31 -0
  34. package/hooks/observability/useTraceDetail.ts +48 -0
  35. package/hooks/observability/useTraces.ts +59 -0
  36. package/lib/agent-factory.ts +354 -0
  37. package/lib/agent-helpers.ts +201 -0
  38. package/lib/db-memory-store.ts +417 -0
  39. package/lib/graph/index.ts +58 -0
  40. package/lib/graph/nodes/combiner.ts +399 -0
  41. package/lib/graph/nodes/router.ts +440 -0
  42. package/lib/graph/orchestrator-graph.ts +386 -0
  43. package/lib/graph/prompts/combiner.md +131 -0
  44. package/lib/graph/prompts/router.md +193 -0
  45. package/lib/graph/types.ts +365 -0
  46. package/lib/guardrails.ts +230 -0
  47. package/lib/index.ts +44 -0
  48. package/lib/logger.ts +70 -0
  49. package/lib/memory-store.ts +168 -0
  50. package/lib/message-serializer.ts +110 -0
  51. package/lib/prompt-renderer.ts +94 -0
  52. package/lib/providers.ts +226 -0
  53. package/lib/streaming.ts +232 -0
  54. package/lib/token-tracker.ts +298 -0
  55. package/lib/tools-builder.ts +192 -0
  56. package/lib/tracer-callbacks.ts +342 -0
  57. package/lib/tracer.ts +350 -0
  58. package/migrations/001_langchain_memory.sql +83 -0
  59. package/migrations/002_token_usage.sql +127 -0
  60. package/migrations/003_observability.sql +257 -0
  61. package/package.json +28 -0
  62. package/plugin.config.ts +170 -0
  63. package/presets/lib/langchain.config.ts.preset +142 -0
  64. package/presets/templates/sector7/ai-observability/[traceId]/page.tsx +91 -0
  65. package/presets/templates/sector7/ai-observability/page.tsx +54 -0
  66. package/types/langchain.types.ts +274 -0
  67. package/types/observability.types.ts +270 -0
@@ -0,0 +1,271 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * ConversationFlow Component
5
+ *
6
+ * Displays a human-readable view of the agent execution flow,
7
+ * showing tool calls, their inputs/outputs, and LLM responses
8
+ * in a chronological conversation format.
9
+ */
10
+
11
+ import { useMemo } from 'react'
12
+ import { useTranslations } from 'next-intl'
13
+ import { Card, CardContent, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
14
+ import { Badge } from '@nextsparkjs/core/components/ui/badge'
15
+ import type { Span, Trace } from '../../types/observability.types'
16
+ import { CollapsibleJson } from './CollapsibleJson'
17
+
18
+ interface ConversationFlowProps {
19
+ trace: Trace
20
+ spans: Span[]
21
+ }
22
+
23
+ interface FlowStep {
24
+ id: string
25
+ type: 'user' | 'llm' | 'tool_call' | 'tool_result' | 'assistant'
26
+ timestamp: string
27
+ content: string
28
+ rawData?: unknown // For JSON data (tool inputs/outputs)
29
+ metadata?: {
30
+ model?: string
31
+ provider?: string
32
+ toolName?: string
33
+ duration?: number
34
+ tokens?: { input?: number; output?: number }
35
+ }
36
+ status?: 'success' | 'error' | 'running'
37
+ }
38
+
39
+ function formatJson(data: unknown): string {
40
+ if (!data) return ''
41
+ if (typeof data === 'string') {
42
+ try {
43
+ const parsed = JSON.parse(data)
44
+ return JSON.stringify(parsed, null, 2)
45
+ } catch {
46
+ return data
47
+ }
48
+ }
49
+ return JSON.stringify(data, null, 2)
50
+ }
51
+
52
+ function truncateContent(content: string, maxLength = 500): string {
53
+ if (content.length <= maxLength) return content
54
+ return content.slice(0, maxLength) + '...'
55
+ }
56
+
57
+ export function ConversationFlow({ trace, spans }: ConversationFlowProps) {
58
+ const t = useTranslations('observability')
59
+
60
+ // Transform spans into a chronological flow
61
+ const flowSteps = useMemo(() => {
62
+ const steps: FlowStep[] = []
63
+
64
+ // 1. User message (from trace input)
65
+ steps.push({
66
+ id: 'user-input',
67
+ type: 'user',
68
+ timestamp: trace.startedAt,
69
+ content: trace.input,
70
+ })
71
+
72
+ // 2. Process spans in order
73
+ const sortedSpans = [...spans].sort(
74
+ (a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()
75
+ )
76
+
77
+ for (const span of sortedSpans) {
78
+ if (span.type === 'llm') {
79
+ // LLM call
80
+ steps.push({
81
+ id: `llm-${span.spanId}`,
82
+ type: 'llm',
83
+ timestamp: span.startedAt,
84
+ content: span.model || span.name,
85
+ metadata: {
86
+ model: span.model,
87
+ provider: span.provider,
88
+ duration: span.durationMs,
89
+ tokens: {
90
+ input: span.inputTokens,
91
+ output: span.outputTokens,
92
+ },
93
+ },
94
+ status: span.status,
95
+ })
96
+ } else if (span.type === 'tool') {
97
+ // Tool call with input
98
+ const toolInput = span.toolInput || span.input
99
+ if (toolInput) {
100
+ steps.push({
101
+ id: `tool-call-${span.spanId}`,
102
+ type: 'tool_call',
103
+ timestamp: span.startedAt,
104
+ content: formatJson(toolInput),
105
+ rawData: toolInput,
106
+ metadata: {
107
+ toolName: span.toolName || span.name.replace('Tool: ', ''),
108
+ duration: span.durationMs,
109
+ },
110
+ status: span.status,
111
+ })
112
+ }
113
+
114
+ // Tool result
115
+ const toolOutput = span.toolOutput || span.output
116
+ if (toolOutput) {
117
+ steps.push({
118
+ id: `tool-result-${span.spanId}`,
119
+ type: 'tool_result',
120
+ timestamp: span.endedAt || span.startedAt,
121
+ content: formatJson(toolOutput),
122
+ rawData: toolOutput,
123
+ metadata: {
124
+ toolName: span.toolName || span.name.replace('Tool: ', ''),
125
+ },
126
+ status: span.status,
127
+ })
128
+ }
129
+ }
130
+ }
131
+
132
+ // 3. Final assistant response (from trace output)
133
+ if (trace.output) {
134
+ steps.push({
135
+ id: 'assistant-output',
136
+ type: 'assistant',
137
+ timestamp: trace.endedAt || trace.startedAt,
138
+ content: trace.output,
139
+ })
140
+ }
141
+
142
+ return steps
143
+ }, [trace, spans])
144
+
145
+ const getStepLabel = (step: FlowStep) => {
146
+ switch (step.type) {
147
+ case 'user':
148
+ return t('flow.userMessage')
149
+ case 'llm':
150
+ return step.metadata?.provider
151
+ ? `${step.metadata.provider} / ${step.metadata?.model || 'unknown'}`
152
+ : `LLM: ${step.metadata?.model || 'unknown'}`
153
+ case 'tool_call':
154
+ return `${t('flow.toolCall')}: ${step.metadata?.toolName}`
155
+ case 'tool_result':
156
+ return `${t('flow.toolResult')}: ${step.metadata?.toolName}`
157
+ case 'assistant':
158
+ return t('flow.assistantResponse')
159
+ default:
160
+ return step.type
161
+ }
162
+ }
163
+
164
+ const getStepStyles = (type: FlowStep['type']) => {
165
+ // Use semantic theme colors with subtle differentiation
166
+ switch (type) {
167
+ case 'user':
168
+ return 'bg-muted/50 border-border'
169
+ case 'llm':
170
+ return 'bg-muted/30 border-border'
171
+ case 'tool_call':
172
+ return 'bg-card border-border'
173
+ case 'tool_result':
174
+ return 'bg-card border-border'
175
+ case 'assistant':
176
+ return 'bg-muted/50 border-border'
177
+ default:
178
+ return 'bg-muted border-border'
179
+ }
180
+ }
181
+
182
+ const getStepTypeLabel = (type: FlowStep['type']) => {
183
+ const labels: Record<FlowStep['type'], string> = {
184
+ user: 'USER',
185
+ llm: 'LLM',
186
+ tool_call: 'CALL',
187
+ tool_result: 'RESULT',
188
+ assistant: 'ASSISTANT',
189
+ }
190
+ return labels[type] || type
191
+ }
192
+
193
+ const formatTime = (timestamp: string) => {
194
+ try {
195
+ return new Date(timestamp).toLocaleTimeString()
196
+ } catch {
197
+ return ''
198
+ }
199
+ }
200
+
201
+ return (
202
+ <Card data-cy="conversation-flow">
203
+ <CardHeader>
204
+ <CardTitle>{t('flow.title')}</CardTitle>
205
+ </CardHeader>
206
+ <CardContent>
207
+ <div className="space-y-3">
208
+ {flowSteps.map((step, index) => (
209
+ <div
210
+ key={step.id}
211
+ className={`relative rounded-lg border p-4 ${getStepStyles(step.type)}`}
212
+ data-cy={`flow-step-${step.type}`}
213
+ >
214
+ {/* Connection line */}
215
+ {index < flowSteps.length - 1 && (
216
+ <div className="absolute left-6 top-full h-3 w-0.5 bg-border" />
217
+ )}
218
+
219
+ {/* Header */}
220
+ <div className="flex items-center justify-between mb-2">
221
+ <div className="flex items-center gap-2">
222
+ <span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
223
+ {getStepTypeLabel(step.type)}
224
+ </span>
225
+ <span className="font-medium text-sm">{getStepLabel(step)}</span>
226
+ {step.status === 'error' && (
227
+ <Badge variant="destructive" className="text-xs">
228
+ Error
229
+ </Badge>
230
+ )}
231
+ </div>
232
+ <div className="flex items-center gap-3 text-xs text-muted-foreground">
233
+ {step.metadata?.duration && (
234
+ <span>{step.metadata.duration}ms</span>
235
+ )}
236
+ {step.metadata?.tokens?.input !== undefined && (
237
+ <span>
238
+ {step.metadata.tokens.input}/{step.metadata.tokens.output} tokens
239
+ </span>
240
+ )}
241
+ <span>{formatTime(step.timestamp)}</span>
242
+ </div>
243
+ </div>
244
+
245
+ {/* Content */}
246
+ <div className="mt-2">
247
+ {step.type === 'user' || step.type === 'assistant' ? (
248
+ <p className="text-sm whitespace-pre-wrap">{step.content}</p>
249
+ ) : step.type === 'llm' ? (
250
+ <p className="text-sm text-muted-foreground">{step.content}</p>
251
+ ) : step.rawData ? (
252
+ <CollapsibleJson data={step.rawData} maxPreviewLength={150} />
253
+ ) : (
254
+ <pre className="text-xs bg-muted p-3 rounded overflow-x-auto whitespace-pre-wrap font-mono">
255
+ {truncateContent(step.content, 1000)}
256
+ </pre>
257
+ )}
258
+ </div>
259
+ </div>
260
+ ))}
261
+
262
+ {flowSteps.length === 0 && (
263
+ <p className="text-center text-muted-foreground py-8">
264
+ {t('flow.noSteps')}
265
+ </p>
266
+ )}
267
+ </div>
268
+ </CardContent>
269
+ </Card>
270
+ )
271
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import { useTranslations } from 'next-intl'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
5
+
6
+ export function DisabledMessage() {
7
+ const t = useTranslations('observability')
8
+
9
+ return (
10
+ <div className="flex items-center justify-center min-h-[400px]" data-cy="observability-disabled">
11
+ <Card className="max-w-md">
12
+ <CardHeader>
13
+ <CardTitle className="text-center">{t('title')}</CardTitle>
14
+ </CardHeader>
15
+ <CardContent>
16
+ <p className="text-center text-muted-foreground">{t('disabled')}</p>
17
+ </CardContent>
18
+ </Card>
19
+ </div>
20
+ )
21
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import { useTranslations } from 'next-intl'
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from '@nextsparkjs/core/components/ui/select'
11
+ import { Input } from '@nextsparkjs/core/components/ui/input'
12
+
13
+ interface FiltersPanelProps {
14
+ status: string
15
+ agent: string
16
+ search: string
17
+ onStatusChange: (status: string) => void
18
+ onAgentChange: (agent: string) => void
19
+ onSearchChange: (search: string) => void
20
+ availableAgents?: string[]
21
+ className?: string
22
+ }
23
+
24
+ export function FiltersPanel({
25
+ status,
26
+ agent,
27
+ search,
28
+ onStatusChange,
29
+ onAgentChange,
30
+ onSearchChange,
31
+ availableAgents = [],
32
+ className = '',
33
+ }: FiltersPanelProps) {
34
+ const t = useTranslations('observability')
35
+
36
+ return (
37
+ <div className={`flex items-center gap-4 ${className}`} data-cy="filters-panel">
38
+ <div className="w-[200px]">
39
+ <Select value={status} onValueChange={onStatusChange}>
40
+ <SelectTrigger data-cy="filter-status">
41
+ <SelectValue placeholder={t('filters.status')} />
42
+ </SelectTrigger>
43
+ <SelectContent>
44
+ <SelectItem value="all">{t('filters.allStatus')}</SelectItem>
45
+ <SelectItem value="success">{t('status.success')}</SelectItem>
46
+ <SelectItem value="error">{t('status.error')}</SelectItem>
47
+ <SelectItem value="running">{t('status.running')}</SelectItem>
48
+ </SelectContent>
49
+ </Select>
50
+ </div>
51
+
52
+ {availableAgents.length > 0 && (
53
+ <div className="w-[200px]">
54
+ <Select value={agent} onValueChange={onAgentChange}>
55
+ <SelectTrigger data-cy="filter-agent">
56
+ <SelectValue placeholder={t('filters.agent')} />
57
+ </SelectTrigger>
58
+ <SelectContent>
59
+ <SelectItem value="all">{t('filters.allAgents')}</SelectItem>
60
+ {availableAgents.map((agentName) => (
61
+ <SelectItem key={agentName} value={agentName}>
62
+ {agentName}
63
+ </SelectItem>
64
+ ))}
65
+ </SelectContent>
66
+ </Select>
67
+ </div>
68
+ )}
69
+
70
+ <div className="flex-1">
71
+ <Input
72
+ type="text"
73
+ placeholder={t('filters.searchPlaceholder')}
74
+ value={search}
75
+ onChange={(e) => onSearchChange(e.target.value)}
76
+ data-cy="filter-search"
77
+ className="max-w-md"
78
+ />
79
+ </div>
80
+ </div>
81
+ )
82
+ }
@@ -0,0 +1,230 @@
1
+ 'use client'
2
+
3
+ import { useState, useMemo, useCallback, useEffect } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useTranslations } from 'next-intl'
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@nextsparkjs/core/components/ui/select'
13
+ import { Button } from '@nextsparkjs/core/components/ui/button'
14
+ import { RefreshCcw } from 'lucide-react'
15
+ import { useTraces } from '../../hooks/observability/useTraces'
16
+ import { TracesTable } from './TracesTable'
17
+ import { FiltersPanel } from './FiltersPanel'
18
+ import type { Trace } from '../../types/observability.types'
19
+
20
+ interface ObservabilityDashboardProps {
21
+ /** Base path for trace detail navigation (e.g., '/superadmin/ai-observability') */
22
+ basePath?: string
23
+ }
24
+
25
+ export function ObservabilityDashboard({ basePath = '/superadmin/ai-observability' }: ObservabilityDashboardProps) {
26
+ const t = useTranslations('observability')
27
+ const router = useRouter()
28
+ const [period, setPeriod] = useState('24h')
29
+ const [statusFilter, setStatusFilter] = useState('all')
30
+ const [agentFilter, setAgentFilter] = useState('all')
31
+ const [searchFilter, setSearchFilter] = useState('')
32
+ const [allTraces, setAllTraces] = useState<Trace[]>([])
33
+ const [cursor, setCursor] = useState<string | undefined>(undefined)
34
+ const [isLoadingMore, setIsLoadingMore] = useState(false)
35
+ const [isReloading, setIsReloading] = useState(false)
36
+ const [refreshKey, setRefreshKey] = useState(0)
37
+
38
+ // Calculate date range based on period (refreshKey forces recalculation)
39
+ const dateRange = useMemo(() => {
40
+ const now = new Date()
41
+ const periods: Record<string, number> = {
42
+ '1h': 1,
43
+ '24h': 24,
44
+ '7d': 24 * 7,
45
+ '30d': 24 * 30,
46
+ }
47
+
48
+ const hoursAgo = periods[period] || 24
49
+ const from = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000)
50
+
51
+ return {
52
+ from: from.toISOString(),
53
+ to: now.toISOString(),
54
+ }
55
+ }, [period, refreshKey])
56
+
57
+ // Reset pagination when filters change
58
+ const resetPagination = useCallback(() => {
59
+ setAllTraces([])
60
+ setCursor(undefined)
61
+ }, [])
62
+
63
+ // Fetch traces with filters
64
+ const tracesQuery = useTraces({
65
+ status: statusFilter === 'all' ? undefined : statusFilter,
66
+ agent: agentFilter === 'all' ? undefined : agentFilter,
67
+ from: dateRange.from,
68
+ to: dateRange.to,
69
+ limit: 50,
70
+ cursor,
71
+ })
72
+
73
+ // Update allTraces when data changes
74
+ useEffect(() => {
75
+ if (tracesQuery.data?.traces) {
76
+ if (cursor) {
77
+ // Append to existing traces (load more)
78
+ setAllTraces((prev) => [...prev, ...tracesQuery.data.traces])
79
+ } else if (isReloading) {
80
+ // Reload: prepend new traces without duplicates
81
+ setAllTraces((prev) => {
82
+ const existingIds = new Set(prev.map((t) => t.traceId))
83
+ const newTraces = tracesQuery.data.traces.filter(
84
+ (t) => !existingIds.has(t.traceId)
85
+ )
86
+ return [...newTraces, ...prev]
87
+ })
88
+ setIsReloading(false)
89
+ } else {
90
+ // Replace traces (new filter/period)
91
+ setAllTraces(tracesQuery.data.traces)
92
+ }
93
+ setIsLoadingMore(false)
94
+ }
95
+ }, [tracesQuery.data, cursor, isReloading])
96
+
97
+ // Extract unique agent names for filter
98
+ const availableAgents = useMemo(() => {
99
+ if (allTraces.length === 0) return []
100
+ const agents = new Set(allTraces.map((t) => t.agentName))
101
+ return Array.from(agents).sort()
102
+ }, [allTraces])
103
+
104
+ // Filter traces by search
105
+ const filteredTraces = useMemo(() => {
106
+ if (allTraces.length === 0) return []
107
+ if (!searchFilter) return allTraces
108
+
109
+ const lowerSearch = searchFilter.toLowerCase()
110
+ return allTraces.filter(
111
+ (trace) =>
112
+ trace.traceId.toLowerCase().includes(lowerSearch) ||
113
+ trace.agentName.toLowerCase().includes(lowerSearch) ||
114
+ trace.input.toLowerCase().includes(lowerSearch)
115
+ )
116
+ }, [allTraces, searchFilter])
117
+
118
+ // Handle load more
119
+ const handleLoadMore = useCallback(() => {
120
+ if (tracesQuery.data?.nextCursor) {
121
+ setIsLoadingMore(true)
122
+ setCursor(tracesQuery.data.nextCursor)
123
+ }
124
+ }, [tracesQuery.data?.nextCursor])
125
+
126
+ // Handle trace selection - navigate to detail page
127
+ const handleSelectTrace = useCallback((traceId: string) => {
128
+ router.push(`${basePath}/${traceId}`)
129
+ }, [router, basePath])
130
+
131
+ // Handle filter changes - reset pagination
132
+ const handleStatusChange = useCallback((value: string) => {
133
+ setStatusFilter(value)
134
+ resetPagination()
135
+ }, [resetPagination])
136
+
137
+ const handleAgentChange = useCallback((value: string) => {
138
+ setAgentFilter(value)
139
+ resetPagination()
140
+ }, [resetPagination])
141
+
142
+ const handlePeriodChange = useCallback((value: string) => {
143
+ setPeriod(value)
144
+ resetPagination()
145
+ }, [resetPagination])
146
+
147
+ // Handle reload - fetch new traces and prepend them
148
+ const handleReload = useCallback(() => {
149
+ setIsReloading(true)
150
+ setRefreshKey((k) => k + 1)
151
+ }, [])
152
+
153
+ return (
154
+ <div className="space-y-6" data-cy="observability-dashboard">
155
+ <div className="flex items-center justify-between">
156
+ <div>
157
+ <h1 className="text-3xl font-bold">{t('title')}</h1>
158
+ <p className="text-muted-foreground mt-1">{t('description')}</p>
159
+ </div>
160
+
161
+ <div className="flex items-center gap-2">
162
+ <Button
163
+ variant="outline"
164
+ size="icon"
165
+ onClick={handleReload}
166
+ disabled={tracesQuery.isFetching}
167
+ data-cy="reload-traces"
168
+ title={t('reload')}
169
+ >
170
+ <RefreshCcw className={`h-4 w-4 ${tracesQuery.isFetching ? 'animate-spin' : ''}`} />
171
+ </Button>
172
+ <div className="w-[150px]">
173
+ <Select value={period} onValueChange={handlePeriodChange}>
174
+ <SelectTrigger data-cy="period-selector">
175
+ <SelectValue />
176
+ </SelectTrigger>
177
+ <SelectContent>
178
+ <SelectItem value="1h">{t('period.1h')}</SelectItem>
179
+ <SelectItem value="24h">{t('period.24h')}</SelectItem>
180
+ <SelectItem value="7d">{t('period.7d')}</SelectItem>
181
+ <SelectItem value="30d">{t('period.30d')}</SelectItem>
182
+ </SelectContent>
183
+ </Select>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <FiltersPanel
189
+ status={statusFilter}
190
+ agent={agentFilter}
191
+ search={searchFilter}
192
+ onStatusChange={handleStatusChange}
193
+ onAgentChange={handleAgentChange}
194
+ onSearchChange={setSearchFilter}
195
+ availableAgents={availableAgents}
196
+ />
197
+
198
+ {tracesQuery.isLoading && (
199
+ <div className="text-center py-12" data-cy="loading">
200
+ <p className="text-muted-foreground">{t('loading')}</p>
201
+ </div>
202
+ )}
203
+
204
+ {tracesQuery.isError && (
205
+ <div className="text-center py-12" data-cy="error">
206
+ <p className="text-destructive">{t('error')}</p>
207
+ </div>
208
+ )}
209
+
210
+ {(tracesQuery.data || allTraces.length > 0) && (
211
+ <>
212
+ <TracesTable traces={filteredTraces} onSelect={handleSelectTrace} />
213
+
214
+ {tracesQuery.data?.hasMore && (
215
+ <div className="flex justify-center">
216
+ <Button
217
+ variant="outline"
218
+ onClick={handleLoadMore}
219
+ disabled={isLoadingMore}
220
+ data-cy="load-more"
221
+ >
222
+ {isLoadingMore ? t('loadingMore') : t('loadMore')}
223
+ </Button>
224
+ </div>
225
+ )}
226
+ </>
227
+ )}
228
+ </div>
229
+ )
230
+ }