fix(monitoring): restore Cloud messages and bot-scoped sessions (#2526)

* fix(monitoring): restore Cloud message persistence and bot-scoped sessions

* fix(migrations): support partial monitoring schemas and align regression fixtures

* test(migrations): complete raw bot session fixture values

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-09-11 14:34:37 +08:00
committed by GitHub
parent ce6b647fe7
commit ff6ad6adc2
36 changed files with 2436 additions and 617 deletions
@@ -4,24 +4,18 @@ import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
import MetricCard from './MetricCard';
import SystemStatusCard from './SystemStatusCards';
import TrafficChart from './TrafficChart';
import {
OverviewMetrics,
MonitoringMessage,
LLMCall,
} from '../../types/monitoring';
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
interface OverviewCardsProps {
metrics: OverviewMetrics | null;
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
traffic?: MonitoringData['traffic'];
loading?: boolean;
refreshKey?: number;
}
export default function OverviewCards({
metrics,
messages = [],
llmCalls = [],
traffic,
loading,
refreshKey,
}: OverviewCardsProps) {
@@ -100,7 +94,7 @@ export default function OverviewCards({
</div>
{/* Traffic Chart */}
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
<TrafficChart traffic={traffic} loading={loading} />
</div>
);
}
@@ -11,119 +11,33 @@ import {
ResponsiveContainer,
Legend,
} from 'recharts';
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
import { MonitoringData } from '../../types/monitoring';
interface TrafficChartProps {
messages: MonitoringMessage[];
llmCalls: LLMCall[];
traffic?: MonitoringData['traffic'];
loading?: boolean;
}
interface ChartDataPoint {
time: string;
timestamp: number;
messages: number;
llmCalls: number;
}
export default function TrafficChart({
messages,
llmCalls,
loading,
}: TrafficChartProps) {
export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
const { t } = useTranslation();
const chartData = useMemo(() => {
const safeMessages = Array.isArray(messages) ? messages : [];
const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : [];
if (!safeMessages.length && !safeLlmCalls.length) {
return [];
}
// Combine all timestamps and find the range
const allTimestamps = [
...safeMessages.map((m) => m.timestamp.getTime()),
...safeLlmCalls.map((c) => c.timestamp.getTime()),
];
if (allTimestamps.length === 0) return [];
const minTime = Math.min(...allTimestamps);
const maxTime = Math.max(...allTimestamps);
const timeRange = maxTime - minTime;
// Determine bucket size based on time range
let bucketSize: number;
let formatTime: (date: Date) => string;
if (timeRange <= 60 * 60 * 1000) {
// <= 1 hour: 5-minute buckets
bucketSize = 5 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 6 * 60 * 60 * 1000) {
// <= 6 hours: 15-minute buckets
bucketSize = 15 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 24 * 60 * 60 * 1000) {
// <= 24 hours: 1-hour buckets
bucketSize = 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
// <= 7 days: 4-hour buckets
bucketSize = 4 * 60 * 60 * 1000;
formatTime = (date) =>
`${date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
} else {
// > 7 days: 1-day buckets
bucketSize = 24 * 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// Create buckets
const buckets: Map<number, ChartDataPoint> = new Map();
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
buckets.set(bucket, {
time: formatTime(new Date(bucket)),
timestamp: bucket,
messages: 0,
llmCalls: 0,
});
}
// Count messages per bucket
safeMessages.forEach((msg) => {
const bucket =
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.messages++;
}
});
// Count LLM calls per bucket
safeLlmCalls.forEach((call) => {
const bucket =
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.llmCalls++;
}
});
return Array.from(buckets.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [messages, llmCalls]);
const chartData = useMemo(
() =>
(traffic?.points ?? []).map((point) => ({
...point,
time: point.timestamp.toLocaleString(
[],
traffic?.bucket === 'day'
? { month: 'short', day: 'numeric' }
: {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
),
})),
[traffic],
);
if (loading) {
return (
@@ -150,7 +64,13 @@ export default function TrafficChart({
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<BarChart3 className="h-[3rem] w-[3rem]" />
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
<div className="text-sm">
{t(
traffic
? 'monitoring.trafficChart.noData'
: 'monitoring.trafficChart.unavailable',
)}
</div>
</div>
</div>
);
@@ -161,6 +81,11 @@ export default function TrafficChart({
<h3 className="text-base font-semibold text-foreground mb-6">
{t('monitoring.trafficChart.title')}
</h3>
{traffic?.truncated && (
<p role="status" className="text-sm text-muted-foreground mb-3">
{t('monitoring.trafficChart.truncated')}
</p>
)}
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
FilterState,
MonitoringData,
@@ -6,7 +6,8 @@ import {
LLMCall,
EmbeddingCall,
} from '../types/monitoring';
import { backendClient } from '@/app/infra/http';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import { parseUTCTimestamp } from '../utils/dateUtils';
/**
@@ -16,6 +17,10 @@ export function useMonitoringData(filterState: FilterState) {
const [data, setData] = useState<MonitoringData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
const requestIdRef = useRef(0);
const scope = JSON.stringify([workspaceUuid, filterState]);
const [requestScope, setRequestScope] = useState<string | null>(null);
// Memoize filter parameters to prevent unnecessary re-renders
const selectedBotsStr = useMemo(
@@ -72,6 +77,12 @@ export function useMonitoringData(filterState: FilterState) {
// Fetch data based on filters
const fetchData = useCallback(async () => {
const requestId = ++requestIdRef.current;
const isCurrent = () =>
requestId === requestIdRef.current &&
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
setRequestScope(scope);
setData(null);
setLoading(true);
setError(null);
@@ -91,6 +102,7 @@ export function useMonitoringData(filterState: FilterState) {
endTime,
limit: 50,
});
if (!isCurrent()) return;
const overview = response?.overview ?? {
total_messages: 0,
@@ -127,6 +139,17 @@ export function useMonitoringData(filterState: FilterState) {
// Transform the response to match MonitoringData interface
const transformedData: MonitoringData = {
traffic: response.traffic
? {
bucket: response.traffic.bucket,
truncated: response.traffic.truncated,
points: response.traffic.points.map((point) => ({
timestamp: parseUTCTimestamp(point.timestamp),
messages: point.messages,
llmCalls: point.llm_calls,
})),
}
: undefined,
overview: {
totalMessages: overview.total_messages,
llmCalls: overview.llm_calls,
@@ -396,22 +419,33 @@ export function useMonitoringData(filterState: FilterState) {
setData(transformedData);
} catch (err) {
if (!isCurrent()) return;
setError(err as Error);
console.error('Failed to fetch monitoring data:', err);
} finally {
setLoading(false);
if (isCurrent()) setLoading(false);
}
}, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);
}, [
getTimeRange,
filterState.selectedBots,
filterState.selectedPipelines,
scope,
workspaceUuid,
]);
// Fetch data when filter state changes
useEffect(() => {
fetchData();
return () => {
requestIdRef.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedBotsStr,
selectedPipelinesStr,
filterState.timeRange,
customDateRangeStr,
workspaceUuid,
]);
// Manual refetch function
@@ -420,9 +454,9 @@ export function useMonitoringData(filterState: FilterState) {
};
return {
data,
loading,
error,
data: requestScope === scope ? data : null,
loading: requestScope !== scope || loading,
error: requestScope === scope ? error : null,
refetch,
};
}
+500 -436
View File
@@ -32,7 +32,7 @@ function MonitoringPageContent() {
currentWorkspace?.permissions.includes('data.export') ?? false;
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
useMonitoringFilters();
const { data, loading, refetch } = useMonitoringData(filterState);
const { data, loading, error, refetch } = useMonitoringData(filterState);
// Counter to force feedbackTimeRange recomputation on manual refresh
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
@@ -174,492 +174,556 @@ function MonitoringPageContent() {
</div>
{/* Content Area */}
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section */}
<OverviewCards
metrics={data?.overview || null}
messages={data?.messages || []}
llmCalls={data?.llmCalls || []}
loading={loading}
/>
{error ? (
<div
role="alert"
className="rounded-xl border border-destructive p-6 space-y-3"
>
<p>{t('monitoring.loadError')}</p>
<Button variant="outline" onClick={handleRefresh}>
{t('common.retry')}
</Button>
</div>
) : (
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section */}
<OverviewCards
metrics={data?.overview || null}
traffic={data?.traffic}
loading={loading}
/>
{/* Tabs Section */}
<div className="bg-card rounded-xl border overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<div className="px-3 pt-4 sm:px-6">
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.messages')}
</TabsTrigger>
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.modelCalls')}
</TabsTrigger>
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.tokens')}
</TabsTrigger>
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.feedback')}
</TabsTrigger>
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.errors')}
</TabsTrigger>
</TabsList>
{/* Tabs Section */}
{!loading && data && (
<div
className="text-sm text-muted-foreground space-y-1"
role="status"
>
{data.totalCount.messages > data.messages.length && (
<p>
{t('monitoring.partialMessages', {
shown: data.messages.length,
total: data.totalCount.messages,
})}
</p>
)}
{data.totalCount.llmCalls + data.totalCount.embeddingCalls >
data.modelCalls.length && (
<p>
{t('monitoring.partialModelCalls', {
shown: data.modelCalls.length,
total:
data.totalCount.llmCalls + data.totalCount.embeddingCalls,
})}
</p>
)}
{(data.totalCount.toolCalls ?? 0) > data.toolCalls.length && (
<p>
{t('monitoring.partialToolCalls', {
shown: data.toolCalls.length,
total: data.totalCount.toolCalls,
})}
</p>
)}
{data.totalCount.errors > data.errors.length && (
<p>
{t('monitoring.partialErrors', {
shown: data.errors.length,
total: data.totalCount.errors,
})}
</p>
)}
</div>
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{!loading && (!data || conversationTurns.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</div>
</div>
)}
)}
<div className="bg-card rounded-xl border overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<div className="px-3 pt-4 sm:px-6">
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.messages')}
</TabsTrigger>
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.modelCalls')}
</TabsTrigger>
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.tokens')}
</TabsTrigger>
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.feedback')}
</TabsTrigger>
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.errors')}
</TabsTrigger>
</TabsList>
</div>
</TabsContent>
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading &&
data &&
data.modelCalls &&
data.modelCalls.length > 0 && (
<div className="space-y-4">
{data.modelCalls.map((call) => (
<div
key={call.id}
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
{/* Query ID - only show if messageId exists */}
{call.messageId && (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={() =>
jumpToMessage(call.messageId!)
}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
</div>
)}
<div className="flex items-center gap-2 mb-2">
{/* Model Type Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.modelType === 'llm'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}
>
{call.modelType === 'llm'
? t('monitoring.modelCalls.llmModel')
: t('monitoring.modelCalls.embeddingModel')}
</span>
{/* Call Type Badge for Embedding */}
{call.modelType === 'embedding' &&
call.callType && (
<span
className={`text-xs px-2 py-1 rounded ${
call.callType === 'retrieve'
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}`}
>
{call.callType === 'retrieve'
? t(
'monitoring.modelCalls.retrieveCall',
)
: t(
'monitoring.modelCalls.embeddingCall',
)}
</span>
)}
{/* Status Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
{/* Model Name */}
<div className="font-medium text-sm text-foreground mb-2">
{call.modelName}
</div>
{/* Context Info - only for LLM calls */}
{call.modelType === 'llm' &&
call.botName &&
call.pipelineName && (
<div className="text-xs text-muted-foreground mb-1">
{call.botName} {call.pipelineName}
</div>
)}
{/* Token Info */}
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex flex-wrap gap-4">
{call.modelType === 'llm' && call.tokens && (
<>
<span>
{t('monitoring.llmCalls.inputTokens')}:{' '}
{call.tokens.input}
</span>
<span>
{t('monitoring.llmCalls.outputTokens')}:{' '}
{call.tokens.output}
</span>
<span>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{call.tokens.total}
</span>
</>
)}
{call.modelType === 'embedding' && (
<>
<span>
{t(
'monitoring.embeddingCalls.promptTokens',
)}
: {call.promptTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.totalTokens',
)}
: {call.totalTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.inputCount',
)}
: {call.inputCount}
</span>
</>
)}
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
</span>
)}
</div>
{/* Knowledge Base Info for Embedding */}
{call.modelType === 'embedding' &&
call.knowledgeBaseId && (
<div>
{t(
'monitoring.embeddingCalls.knowledgeBase',
)}
: {call.knowledgeBaseId}
</div>
)}
{/* Query Text for Embedding Retrieve */}
{call.modelType === 'embedding' &&
call.queryText && (
<div className="mt-2 p-2 bg-muted rounded text-sm">
<span className="text-muted-foreground">
{t(
'monitoring.embeddingCalls.queryText',
)}
:{' '}
</span>
<span className="text-foreground">
{call.queryText.length > 100
? call.queryText.substring(0, 100) +
'...'
: call.queryText}
</span>
</div>
)}
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
</div>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()}
</span>
</div>
</div>
))}
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loading &&
(!data ||
!data.modelCalls ||
data.modelCalls.length === 0) && (
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{!loading && (!data || conversationTurns.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<Sparkles className="h-[3rem] w-[3rem]" />
<MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.modelCalls.noData')}
{t('monitoring.messageList.noMessages')}
</div>
</div>
)}
</div>
</TabsContent>
</div>
</TabsContent>
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
<TokenMonitoring
botIds={
filterState.selectedBots.length > 0
? filterState.selectedBots
: undefined
}
pipelineIds={
filterState.selectedPipelines.length > 0
? filterState.selectedPipelines
: undefined
}
startTime={feedbackTimeRange.startTime}
endTime={feedbackTimeRange.endTime}
refreshKey={feedbackRefreshKey}
/>
</TabsContent>
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && (
<>
{/* Feedback Stats Cards */}
<div className="mb-6">
<FeedbackStatsCards
stats={feedbackStats}
loading={feedbackLoading}
/>
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{/* Feedback List */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('monitoring.feedback.feedbackList')}
</h3>
<FeedbackList
feedback={feedbackList}
loading={feedbackLoading}
onViewMessage={jumpToMessage}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.errors && data.errors.length > 0 && (
<div className="space-y-4">
{data.errors.map((error) => (
<div
key={error.id}
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
>
{/* Error Header - Always Visible */}
<div
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
onClick={() => toggleErrorExpand(error.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedErrorId === error.id ? (
<ChevronDown className="w-5 h-5 text-red-500" />
) : (
<ChevronRight className="w-5 h-5 text-red-500" />
)}
</div>
{/* Error Info */}
{!loading &&
data &&
data.modelCalls &&
data.modelCalls.length > 0 && (
<div className="space-y-4">
{data.modelCalls.map((call) => (
<div
key={call.id}
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
{/* Query ID */}
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {error.messageId || '-'}
</span>
{error.messageId && (
{/* Query ID - only show if messageId exists */}
{call.messageId && (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
jumpToMessage(error.messageId!);
}}
onClick={() =>
jumpToMessage(call.messageId!)
}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
)}
</div>
</div>
)}
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-red-700 dark:text-red-300">
{error.errorType}
{/* Model Type Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.modelType === 'llm'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}
>
{call.modelType === 'llm'
? t('monitoring.modelCalls.llmModel')
: t(
'monitoring.modelCalls.embeddingModel',
)}
</span>
<span className="text-red-400"></span>
<span className="text-sm text-muted-foreground">
{error.botName}
</span>
<span className="text-red-400"></span>
<span className="text-sm text-muted-foreground">
{error.pipelineName}
{/* Call Type Badge for Embedding */}
{call.modelType === 'embedding' &&
call.callType && (
<span
className={`text-xs px-2 py-1 rounded ${
call.callType === 'retrieve'
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}`}
>
{call.callType === 'retrieve'
? t(
'monitoring.modelCalls.retrieveCall',
)
: t(
'monitoring.modelCalls.embeddingCall',
)}
</span>
)}
{/* Status Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
{error.errorMessage}
</p>
{/* Model Name */}
<div className="font-medium text-sm text-foreground mb-2">
{call.modelName}
</div>
{/* Context Info - only for LLM calls */}
{call.modelType === 'llm' &&
call.botName &&
call.pipelineName && (
<div className="text-xs text-muted-foreground mb-1">
{call.botName} {call.pipelineName}
</div>
)}
{/* Token Info */}
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex flex-wrap gap-4">
{call.modelType === 'llm' &&
call.tokens && (
<>
<span>
{t(
'monitoring.llmCalls.inputTokens',
)}
: {call.tokens.input}
</span>
<span>
{t(
'monitoring.llmCalls.outputTokens',
)}
: {call.tokens.output}
</span>
<span>
{t(
'monitoring.llmCalls.totalTokens',
)}
: {call.tokens.total}
</span>
</>
)}
{call.modelType === 'embedding' && (
<>
<span>
{t(
'monitoring.embeddingCalls.promptTokens',
)}
: {call.promptTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.totalTokens',
)}
: {call.totalTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.inputCount',
)}
: {call.inputCount}
</span>
</>
)}
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
</span>
)}
</div>
{/* Knowledge Base Info for Embedding */}
{call.modelType === 'embedding' &&
call.knowledgeBaseId && (
<div>
{t(
'monitoring.embeddingCalls.knowledgeBase',
)}
: {call.knowledgeBaseId}
</div>
)}
{/* Query Text for Embedding Retrieve */}
{call.modelType === 'embedding' &&
call.queryText && (
<div className="mt-2 p-2 bg-muted rounded text-sm">
<span className="text-muted-foreground">
{t(
'monitoring.embeddingCalls.queryText',
)}
:{' '}
</span>
<span className="text-foreground">
{call.queryText.length > 100
? call.queryText.substring(0, 100) +
'...'
: call.queryText}
</span>
</div>
)}
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
</div>
)}
</div>
</div>
{/* Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{error.timestamp.toLocaleString()}
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()}
</span>
</div>
</div>
</div>
))}
</div>
)}
{/* Expanded Details */}
{expandedErrorId === error.id && (
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
{/* Error Details */}
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
{t('monitoring.errors.errorMessage')}
</h4>
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
{error.errorMessage}
{!loading &&
(!data ||
!data.modelCalls ||
data.modelCalls.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<Sparkles className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.modelCalls.noData')}
</div>
</div>
)}
</div>
</TabsContent>
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
<TokenMonitoring
botIds={
filterState.selectedBots.length > 0
? filterState.selectedBots
: undefined
}
pipelineIds={
filterState.selectedPipelines.length > 0
? filterState.selectedPipelines
: undefined
}
startTime={feedbackTimeRange.startTime}
endTime={feedbackTimeRange.endTime}
refreshKey={feedbackRefreshKey}
/>
</TabsContent>
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && (
<>
{/* Feedback Stats Cards */}
<div className="mb-6">
<FeedbackStatsCards
stats={feedbackStats}
loading={feedbackLoading}
/>
</div>
{/* Feedback List */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('monitoring.feedback.feedbackList')}
</h3>
<FeedbackList
feedback={feedbackList}
loading={feedbackLoading}
onViewMessage={jumpToMessage}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading &&
data &&
data.errors &&
data.errors.length > 0 && (
<div className="space-y-4">
{data.errors.map((error) => (
<div
key={error.id}
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
>
{/* Error Header - Always Visible */}
<div
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
onClick={() => toggleErrorExpand(error.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedErrorId === error.id ? (
<ChevronDown className="w-5 h-5 text-red-500" />
) : (
<ChevronRight className="w-5 h-5 text-red-500" />
)}
</div>
{/* Error Info */}
<div className="flex-1">
{/* Query ID */}
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {error.messageId || '-'}
</span>
{error.messageId && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
jumpToMessage(error.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
)}
</div>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-red-700 dark:text-red-300">
{error.errorType}
</span>
<span className="text-red-400"></span>
<span className="text-sm text-muted-foreground">
{error.botName}
</span>
<span className="text-red-400"></span>
<span className="text-sm text-muted-foreground">
{error.pipelineName}
</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
{error.errorMessage}
</p>
</div>
</div>
{/* Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{error.timestamp.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Context Info */}
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.messageList.viewDetails')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.messageList.bot')}
</div>
<div className="font-medium text-foreground">
{error.botName}
{/* Expanded Details */}
{expandedErrorId === error.id && (
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
{/* Error Details */}
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
{t('monitoring.errors.errorMessage')}
</h4>
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
{error.errorMessage}
</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.messageList.pipeline')}
</div>
<div className="font-medium text-foreground">
{error.pipelineName}
{/* Context Info */}
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.messageList.viewDetails')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.messageList.bot')}
</div>
<div className="font-medium text-foreground">
{error.botName}
</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.messageList.pipeline')}
</div>
<div className="font-medium text-foreground">
{error.pipelineName}
</div>
</div>
{error.sessionId && (
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.sessions.sessionId')}
</div>
<div className="font-medium text-foreground truncate">
{error.sessionId}
</div>
</div>
)}
</div>
</div>
{error.sessionId && (
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.sessions.sessionId')}
</div>
<div className="font-medium text-foreground truncate">
{error.sessionId}
</div>
{/* Stack Trace */}
{error.stackTrace && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.errors.stackTrace')}
</h4>
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
</div>
)}
</div>
</div>
{/* Stack Trace */}
{error.stackTrace && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.errors.stackTrace')}
</h4>
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
</div>
)}
</div>
)}
</div>
)}
))}
</div>
))}
</div>
)}
)}
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
<div className="text-sm text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
<div className="text-sm text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</div>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
)}
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
)}
</div>
);
}
@@ -217,6 +217,11 @@ export interface FeedbackStats {
}
export interface MonitoringData {
traffic?: {
bucket: 'hour' | 'day';
points: Array<{ timestamp: Date; messages: number; llmCalls: number }>;
truncated: boolean;
};
overview: OverviewMetrics;
messages: MonitoringMessage[];
llmCalls: LLMCall[];
@@ -155,17 +155,18 @@ function findTurnBySessionTime(
sessionTurns: Map<string, ConversationTurn[]>,
sessionId: string | undefined,
timestamp: Date,
botId: string,
): ConversationTurn | undefined {
if (!sessionId) {
return undefined;
}
const turns = sessionTurns.get(sessionId);
const turns = sessionTurns.get(JSON.stringify([botId, sessionId]));
if (!turns?.length) {
return undefined;
}
let nearest = turns[0];
let nearest: ConversationTurn | undefined;
const targetTime = timestamp.getTime();
for (const turn of turns) {
@@ -203,15 +204,16 @@ export function buildConversationTurns(
for (const message of visibleMessages) {
const role = normalizeRole(message, activityMessageIds);
const previousTurn = lastTurnBySession.get(message.sessionId);
const sessionKey = JSON.stringify([message.botId, message.sessionId]);
const previousTurn = lastTurnBySession.get(sessionKey);
const shouldStartTurn = role === 'user' || !previousTurn;
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
if (shouldStartTurn) {
const turns = sessionTurns.get(message.sessionId) ?? [];
const turns = sessionTurns.get(sessionKey) ?? [];
turns.push(turn);
sessionTurns.set(message.sessionId, turns);
lastTurnBySession.set(message.sessionId, turn);
sessionTurns.set(sessionKey, turns);
lastTurnBySession.set(sessionKey, turn);
}
addMessageToTurn(turn, message, role);
@@ -221,9 +223,14 @@ export function buildConversationTurns(
const allTurns = Array.from(sessionTurns.values()).flat();
for (const call of llmCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
const turn = call.messageId
? messageIdToTurn.get(call.messageId)
: findTurnBySessionTime(
sessionTurns,
call.sessionId,
call.timestamp,
call.botId,
);
if (!turn) {
continue;
@@ -243,9 +250,14 @@ export function buildConversationTurns(
}
for (const call of toolCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
const turn = call.messageId
? messageIdToTurn.get(call.messageId)
: findTurnBySessionTime(
sessionTurns,
call.sessionId,
call.timestamp,
call.botId,
);
if (!turn) {
continue;
@@ -262,9 +274,14 @@ export function buildConversationTurns(
}
for (const error of errors) {
const turn =
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
const turn = error.messageId
? messageIdToTurn.get(error.messageId)
: findTurnBySessionTime(
sessionTurns,
error.sessionId,
error.timestamp,
error.botId,
);
if (!turn) {
continue;