@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.
- package/.env.example +41 -0
- package/api/observability/metrics/route.ts +110 -0
- package/api/observability/traces/[traceId]/route.ts +398 -0
- package/api/observability/traces/route.ts +205 -0
- package/api/sessions/route.ts +332 -0
- package/components/observability/CollapsibleJson.tsx +71 -0
- package/components/observability/CompactTimeline.tsx +75 -0
- package/components/observability/ConversationFlow.tsx +271 -0
- package/components/observability/DisabledMessage.tsx +21 -0
- package/components/observability/FiltersPanel.tsx +82 -0
- package/components/observability/ObservabilityDashboard.tsx +230 -0
- package/components/observability/SpansList.tsx +210 -0
- package/components/observability/TraceDetail.tsx +335 -0
- package/components/observability/TraceStatusBadge.tsx +39 -0
- package/components/observability/TracesTable.tsx +97 -0
- package/components/observability/index.ts +7 -0
- package/docs/01-getting-started/01-overview.md +196 -0
- package/docs/01-getting-started/02-installation.md +368 -0
- package/docs/01-getting-started/03-configuration.md +794 -0
- package/docs/02-core-concepts/01-architecture.md +566 -0
- package/docs/02-core-concepts/02-agents.md +597 -0
- package/docs/02-core-concepts/03-tools.md +689 -0
- package/docs/03-orchestration/01-graph-orchestrator.md +809 -0
- package/docs/03-orchestration/02-legacy-react.md +650 -0
- package/docs/04-advanced/01-observability.md +645 -0
- package/docs/04-advanced/02-token-tracking.md +469 -0
- package/docs/04-advanced/03-streaming.md +476 -0
- package/docs/04-advanced/04-guardrails.md +597 -0
- package/docs/05-reference/01-api-reference.md +1403 -0
- package/docs/05-reference/02-customization.md +646 -0
- package/docs/05-reference/03-examples.md +881 -0
- package/docs/index.md +85 -0
- package/hooks/observability/useMetrics.ts +31 -0
- package/hooks/observability/useTraceDetail.ts +48 -0
- package/hooks/observability/useTraces.ts +59 -0
- package/lib/agent-factory.ts +354 -0
- package/lib/agent-helpers.ts +201 -0
- package/lib/db-memory-store.ts +417 -0
- package/lib/graph/index.ts +58 -0
- package/lib/graph/nodes/combiner.ts +399 -0
- package/lib/graph/nodes/router.ts +440 -0
- package/lib/graph/orchestrator-graph.ts +386 -0
- package/lib/graph/prompts/combiner.md +131 -0
- package/lib/graph/prompts/router.md +193 -0
- package/lib/graph/types.ts +365 -0
- package/lib/guardrails.ts +230 -0
- package/lib/index.ts +44 -0
- package/lib/logger.ts +70 -0
- package/lib/memory-store.ts +168 -0
- package/lib/message-serializer.ts +110 -0
- package/lib/prompt-renderer.ts +94 -0
- package/lib/providers.ts +226 -0
- package/lib/streaming.ts +232 -0
- package/lib/token-tracker.ts +298 -0
- package/lib/tools-builder.ts +192 -0
- package/lib/tracer-callbacks.ts +342 -0
- package/lib/tracer.ts +350 -0
- package/migrations/001_langchain_memory.sql +83 -0
- package/migrations/002_token_usage.sql +127 -0
- package/migrations/003_observability.sql +257 -0
- package/package.json +28 -0
- package/plugin.config.ts +170 -0
- package/presets/lib/langchain.config.ts.preset +142 -0
- package/presets/templates/sector7/ai-observability/[traceId]/page.tsx +91 -0
- package/presets/templates/sector7/ai-observability/page.tsx +54 -0
- package/types/langchain.types.ts +274 -0
- 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
|
+
}
|