mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
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:
@@ -157,6 +157,9 @@ const BotSessionMonitor = forwardRef<
|
||||
const [messagePage, setMessagePage] = useState(0);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sessionError, setSessionError] = useState(false);
|
||||
const [messageError, setMessageError] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const [feedbackMap, setFeedbackMap] = useState<
|
||||
Record<string, SessionFeedback>
|
||||
@@ -236,6 +239,8 @@ const BotSessionMonitor = forwardRef<
|
||||
const loadSessions = useCallback(async () => {
|
||||
const requestId = ++sessionRequestIdRef.current;
|
||||
setLoadingSessions(true);
|
||||
setSessionError(false);
|
||||
setSessions([]);
|
||||
try {
|
||||
const response = await httpClient.getBotSessions(botId, {
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
@@ -254,6 +259,7 @@ const BotSessionMonitor = forwardRef<
|
||||
} catch (error) {
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
setSessionError(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
@@ -274,12 +280,18 @@ const BotSessionMonitor = forwardRef<
|
||||
async (sessionId: string, page: number) => {
|
||||
const requestId = ++messageRequestIdRef.current;
|
||||
setLoadingMessages(true);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setToolCalls([]);
|
||||
setFeedbackMap({});
|
||||
setExpandedToolCallIds({});
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(
|
||||
sessionId,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
page * MESSAGE_PAGE_SIZE,
|
||||
botId,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
@@ -290,22 +302,19 @@ const BotSessionMonitor = forwardRef<
|
||||
setMessageTotal(messagesRes.total ?? 0);
|
||||
|
||||
try {
|
||||
const analysisParams = new URLSearchParams();
|
||||
if (sorted.length > 0) {
|
||||
analysisParams.set('startTime', sorted[0].timestamp);
|
||||
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
|
||||
}
|
||||
const analysisRes = await httpClient.get<{
|
||||
const analysisRes = await httpClient.getSessionAnalysis<{
|
||||
tool_calls?: SessionToolCall[];
|
||||
}>(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
|
||||
);
|
||||
}>(sessionId, botId, {
|
||||
startTime: sorted[0]?.timestamp,
|
||||
endTime: sorted[sorted.length - 1]?.timestamp,
|
||||
});
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||
} catch (analysisError) {
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
console.error('Failed to load session tool calls:', analysisError);
|
||||
setToolCalls([]);
|
||||
setAnalysisError(true);
|
||||
}
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
@@ -337,6 +346,7 @@ const BotSessionMonitor = forwardRef<
|
||||
} catch (error) {
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
setMessageError(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
@@ -349,6 +359,9 @@ const BotSessionMonitor = forwardRef<
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
return () => {
|
||||
sessionRequestIdRef.current += 1;
|
||||
};
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -362,12 +375,17 @@ const BotSessionMonitor = forwardRef<
|
||||
} else {
|
||||
messageRequestIdRef.current += 1;
|
||||
setLoadingMessages(false);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setMessageTotal(0);
|
||||
setToolCalls([]);
|
||||
setExpandedToolCallIds({});
|
||||
setFeedbackMap({});
|
||||
}
|
||||
return () => {
|
||||
messageRequestIdRef.current += 1;
|
||||
};
|
||||
}, [selectedSessionId, messagePage, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -728,6 +746,20 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : sessionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 space-y-2 text-sm text-destructive"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSessions}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
@@ -898,10 +930,46 @@ const BotSessionMonitor = forwardRef<
|
||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{analysisError && !loadingMessages && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>
|
||||
{t('monitoring.toolCalls.title')}:{' '}
|
||||
{t('monitoring.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messageError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -563,10 +563,24 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getSessionAnalysis<T>(
|
||||
sessionId: string,
|
||||
botId: string,
|
||||
options: { startTime?: string; endTime?: string } = {},
|
||||
): Promise<T> {
|
||||
const queryParams = new URLSearchParams({ botId });
|
||||
if (options.startTime) queryParams.set('startTime', options.startTime);
|
||||
if (options.endTime) queryParams.set('endTime', options.endTime);
|
||||
return this.get(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number = 200,
|
||||
offset: number = 0,
|
||||
botId?: string,
|
||||
): Promise<{
|
||||
messages: Array<{
|
||||
id: string;
|
||||
@@ -590,6 +604,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('sessionId', sessionId);
|
||||
if (botId) queryParams.append('botId', botId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`);
|
||||
@@ -1496,6 +1511,11 @@ export class BackendClient extends BaseHttpClient {
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: string; messages: number; llm_calls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: {
|
||||
total_messages: number;
|
||||
llm_calls: number;
|
||||
|
||||
@@ -1644,7 +1644,16 @@ const enUS = {
|
||||
queryVariables: {
|
||||
title: 'Query Variables',
|
||||
},
|
||||
loadError: 'Failed to load monitoring data',
|
||||
partialMessages:
|
||||
'Showing {{shown}} of {{total}} messages. Conversation traces may be incomplete.',
|
||||
partialModelCalls: 'Showing {{shown}} of {{total}} model calls.',
|
||||
partialToolCalls:
|
||||
'Showing {{shown}} of {{total}} tool calls. Conversation traces may be incomplete.',
|
||||
partialErrors: 'Showing {{shown}} of {{total}} errors.',
|
||||
trafficChart: {
|
||||
unavailable: 'Traffic aggregation unavailable',
|
||||
truncated: 'Traffic range truncated. Choose a shorter time range.',
|
||||
title: 'Traffic Overview',
|
||||
messages: 'Messages',
|
||||
llmCalls: 'LLM Calls',
|
||||
|
||||
@@ -1602,7 +1602,17 @@ const esES = {
|
||||
queryVariables: {
|
||||
title: 'Variables de consulta',
|
||||
},
|
||||
loadError: 'No se pudieron cargar los datos de monitoreo',
|
||||
partialMessages:
|
||||
'Se muestran {{shown}} de {{total}} mensajes. Las trazas de conversación pueden estar incompletas.',
|
||||
partialModelCalls: 'Se muestran {{shown}} de {{total}} llamadas al modelo.',
|
||||
partialToolCalls:
|
||||
'Se muestran {{shown}} de {{total}} llamadas a herramientas. Las trazas de conversación pueden estar incompletas.',
|
||||
partialErrors: 'Se muestran {{shown}} de {{total}} errores.',
|
||||
trafficChart: {
|
||||
unavailable: 'Agregación de tráfico no disponible',
|
||||
truncated:
|
||||
'Rango de tráfico truncado. Selecciona un intervalo más corto.',
|
||||
title: 'Resumen de tráfico',
|
||||
messages: 'Mensajes',
|
||||
llmCalls: 'Llamadas LLM',
|
||||
|
||||
@@ -1653,7 +1653,17 @@ const jaJP = {
|
||||
queryVariables: {
|
||||
title: 'クエリ変数',
|
||||
},
|
||||
loadError: 'モニタリングデータを読み込めませんでした',
|
||||
partialMessages:
|
||||
'全 {{total}} 件中 {{shown}} 件のメッセージを表示。会話トレースは不完全な場合があります。',
|
||||
partialModelCalls: '全 {{total}} 件中 {{shown}} 件のモデル呼び出しを表示。',
|
||||
partialToolCalls:
|
||||
'全 {{total}} 件中 {{shown}} 件のツール呼び出しを表示。会話トレースは不完全な場合があります。',
|
||||
partialErrors: '全 {{total}} 件中 {{shown}} 件のエラーを表示。',
|
||||
trafficChart: {
|
||||
unavailable: 'トラフィック集計を利用できません',
|
||||
truncated:
|
||||
'トラフィック範囲が切り詰められています。短い期間を選択してください。',
|
||||
title: 'トラフィック概要',
|
||||
messages: 'メッセージ',
|
||||
llmCalls: 'LLM呼び出し',
|
||||
|
||||
@@ -1574,7 +1574,16 @@ const ruRU = {
|
||||
queryVariables: {
|
||||
title: 'Переменные запроса',
|
||||
},
|
||||
loadError: 'Не удалось загрузить данные мониторинга',
|
||||
partialMessages:
|
||||
'Показано {{shown}} из {{total}} сообщений. Трассировки диалогов могут быть неполными.',
|
||||
partialModelCalls: 'Показано {{shown}} из {{total}} вызовов модели.',
|
||||
partialToolCalls:
|
||||
'Показано {{shown}} из {{total}} вызовов инструментов. Трассировки диалогов могут быть неполными.',
|
||||
partialErrors: 'Показано {{shown}} из {{total}} ошибок.',
|
||||
trafficChart: {
|
||||
unavailable: 'Агрегированные данные трафика недоступны',
|
||||
truncated: 'Диапазон трафика обрезан. Выберите более короткий период.',
|
||||
title: 'Обзор трафика',
|
||||
messages: 'Сообщения',
|
||||
llmCalls: 'Вызовы LLM',
|
||||
|
||||
@@ -1543,7 +1543,17 @@ const thTH = {
|
||||
queryVariables: {
|
||||
title: 'ตัวแปรคำค้นหา',
|
||||
},
|
||||
loadError: 'โหลดข้อมูลการตรวจสอบไม่สำเร็จ',
|
||||
partialMessages:
|
||||
'แสดง {{shown}} จาก {{total}} ข้อความ ประวัติการสนทนาอาจไม่ครบถ้วน',
|
||||
partialModelCalls: 'แสดง {{shown}} จาก {{total}} การเรียกโมเดล',
|
||||
partialToolCalls:
|
||||
'แสดง {{shown}} จาก {{total}} การเรียกเครื่องมือ ประวัติการสนทนาอาจไม่ครบถ้วน',
|
||||
partialErrors: 'แสดง {{shown}} จาก {{total}} ข้อผิดพลาด',
|
||||
trafficChart: {
|
||||
unavailable: 'ไม่มีข้อมูลสรุปปริมาณการใช้งาน',
|
||||
truncated:
|
||||
'ช่วงข้อมูลปริมาณการใช้งานถูกตัดทอน โปรดเลือกช่วงเวลาที่สั้นลง',
|
||||
title: 'ภาพรวมปริมาณการใช้งาน',
|
||||
messages: 'ข้อความ',
|
||||
llmCalls: 'การเรียก LLM',
|
||||
|
||||
@@ -1567,7 +1567,17 @@ const viVN = {
|
||||
queryVariables: {
|
||||
title: 'Biến truy vấn',
|
||||
},
|
||||
loadError: 'Không thể tải dữ liệu giám sát',
|
||||
partialMessages:
|
||||
'Hiển thị {{shown}} trên {{total}} tin nhắn. Dấu vết hội thoại có thể không đầy đủ.',
|
||||
partialModelCalls: 'Hiển thị {{shown}} trên {{total}} lượt gọi mô hình.',
|
||||
partialToolCalls:
|
||||
'Hiển thị {{shown}} trên {{total}} lượt gọi công cụ. Dấu vết hội thoại có thể không đầy đủ.',
|
||||
partialErrors: 'Hiển thị {{shown}} trên {{total}} lỗi.',
|
||||
trafficChart: {
|
||||
unavailable: 'Không có dữ liệu tổng hợp lưu lượng',
|
||||
truncated:
|
||||
'Phạm vi lưu lượng bị cắt ngắn. Hãy chọn khoảng thời gian ngắn hơn.',
|
||||
title: 'Tổng quan lưu lượng',
|
||||
messages: 'Tin nhắn',
|
||||
llmCalls: 'Cuộc gọi LLM',
|
||||
|
||||
@@ -1572,7 +1572,16 @@ const zhHans = {
|
||||
queryVariables: {
|
||||
title: '查询变量',
|
||||
},
|
||||
loadError: '监控数据加载失败',
|
||||
partialMessages:
|
||||
'显示 {{total}} 条消息中的 {{shown}} 条,对话轨迹可能不完整。',
|
||||
partialModelCalls: '显示 {{total}} 次模型调用中的 {{shown}} 次。',
|
||||
partialToolCalls:
|
||||
'显示 {{total}} 次工具调用中的 {{shown}} 次,对话轨迹可能不完整。',
|
||||
partialErrors: '显示 {{total}} 条错误中的 {{shown}} 条。',
|
||||
trafficChart: {
|
||||
unavailable: '流量聚合数据不可用',
|
||||
truncated: '流量时间范围已截断,请选择更短的时间范围。',
|
||||
title: '流量概览',
|
||||
messages: '消息数',
|
||||
llmCalls: 'LLM调用',
|
||||
|
||||
@@ -1495,7 +1495,16 @@ const zhHant = {
|
||||
queryVariables: {
|
||||
title: '查詢變數',
|
||||
},
|
||||
loadError: '監控資料載入失敗',
|
||||
partialMessages:
|
||||
'顯示 {{total}} 則訊息中的 {{shown}} 則,對話軌跡可能不完整。',
|
||||
partialModelCalls: '顯示 {{total}} 次模型呼叫中的 {{shown}} 次。',
|
||||
partialToolCalls:
|
||||
'顯示 {{total}} 次工具呼叫中的 {{shown}} 次,對話軌跡可能不完整。',
|
||||
partialErrors: '顯示 {{total}} 筆錯誤中的 {{shown}} 筆。',
|
||||
trafficChart: {
|
||||
unavailable: '流量彙總資料無法使用',
|
||||
truncated: '流量時間範圍已截斷,請選擇較短的時間範圍。',
|
||||
title: '流量概覽',
|
||||
messages: '訊息',
|
||||
llmCalls: 'LLM呼叫',
|
||||
|
||||
@@ -66,7 +66,381 @@ function toolCall(
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('bot session request recovery', () => {
|
||||
for (const failure of [
|
||||
'initial list',
|
||||
'list page',
|
||||
'session switch',
|
||||
'message page',
|
||||
'analysis',
|
||||
]) {
|
||||
test(`${failure} failure is visible and retry recovers`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let failing = true;
|
||||
await page.route('**/api/v1/monitoring/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const offset = Number(url.searchParams.get('offset') || 0);
|
||||
const second = url.searchParams.get('sessionId') === 'person-second';
|
||||
const list = url.pathname.endsWith('/sessions');
|
||||
const message = url.pathname.endsWith('/messages');
|
||||
const analysis = url.pathname.endsWith('/analysis');
|
||||
if (!list && !message && !analysis) return route.fallback();
|
||||
const fail =
|
||||
failing &&
|
||||
((list && failure === 'initial list') ||
|
||||
(list && failure === 'list page' && offset > 0) ||
|
||||
(message && failure === 'session switch' && second) ||
|
||||
(message && failure === 'message page' && offset > 0) ||
|
||||
(analysis && failure === 'analysis'));
|
||||
if (fail)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, message: 'fixture failure' },
|
||||
});
|
||||
const data = list
|
||||
? {
|
||||
sessions: [sessionId, 'person-second'].map((id, i) => ({
|
||||
session_id: id,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 51,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
user_name: offset ? `Page two ${i}` : `Recovery user ${i}`,
|
||||
})),
|
||||
total: 21,
|
||||
}
|
||||
: message
|
||||
? {
|
||||
messages: [
|
||||
sessionMessage(
|
||||
'recovery-message',
|
||||
'user',
|
||||
0,
|
||||
second
|
||||
? 'Second session message'
|
||||
: offset
|
||||
? 'Second page message'
|
||||
: 'Successful message',
|
||||
),
|
||||
],
|
||||
total: 51,
|
||||
}
|
||||
: {
|
||||
tool_calls: [
|
||||
toolCall('recovery-tool', 1, 'recovered_tool', 40),
|
||||
],
|
||||
};
|
||||
return route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
if (failure === 'list page') {
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
||||
} else if (failure !== 'initial list') {
|
||||
await page.getByRole('button', { name: /Recovery user 0/ }).click();
|
||||
if (failure !== 'analysis') {
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
if (failure === 'session switch')
|
||||
await page.getByRole('button', { name: /Recovery user 1/ }).click();
|
||||
else
|
||||
await page
|
||||
.getByRole('button', { name: 'Next', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No sessions found', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'analysis') {
|
||||
await expect(page.getByRole('alert')).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
if (failure === 'list page')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Recovery user 0/ }),
|
||||
).toHaveCount(0);
|
||||
failing = false;
|
||||
await page
|
||||
.getByRole('alert')
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
if (failure === 'initial list' || failure === 'list page') {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: failure === 'list page' ? /Page two 0/ : /Recovery user 0/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText(
|
||||
failure === 'session switch'
|
||||
? 'Second session message'
|
||||
: failure === 'message page'
|
||||
? 'Second page message'
|
||||
: 'Successful message',
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('recovered_tool', { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('bot session request races', () => {
|
||||
for (const kind of ['messages', 'analysis', 'sessions']) {
|
||||
for (const status of [200, 500]) {
|
||||
test(`ignores stale ${kind} ${status} after switching`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let held = false;
|
||||
let released = false;
|
||||
await page.route('**/api/v1/monitoring/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const list = url.pathname.endsWith('/sessions');
|
||||
const message = url.pathname.endsWith('/messages');
|
||||
const analysis = url.pathname.endsWith('/analysis');
|
||||
if (!list && !message && !analysis) return route.fallback();
|
||||
const old =
|
||||
kind === 'sessions'
|
||||
? url.searchParams.get('userQuery') === 'old'
|
||||
: message
|
||||
? url.searchParams.get('sessionId') === sessionId
|
||||
: url.pathname.includes(sessionId);
|
||||
const isHeld = old && url.pathname.endsWith(`/${kind}`);
|
||||
if (isHeld) {
|
||||
held = true;
|
||||
await gate;
|
||||
if (status === 500) {
|
||||
await route.fulfill({ status: 500, json: { code: 500 } });
|
||||
released = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const data = list
|
||||
? {
|
||||
sessions: [sessionId, 'person-new'].map((id, i) => ({
|
||||
session_id: id,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 1,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
user_name: isHeld ? 'Stale list' : `Race user ${i}`,
|
||||
})),
|
||||
total: 2,
|
||||
}
|
||||
: message
|
||||
? {
|
||||
messages: [
|
||||
sessionMessage(
|
||||
'race-message',
|
||||
'user',
|
||||
0,
|
||||
old ? 'Old message' : 'Current message',
|
||||
),
|
||||
],
|
||||
total: 1,
|
||||
}
|
||||
: {
|
||||
tool_calls: [
|
||||
toolCall(
|
||||
'race-tool',
|
||||
1,
|
||||
old ? 'old_tool' : 'current_tool',
|
||||
40,
|
||||
),
|
||||
],
|
||||
};
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
if (isHeld) released = true;
|
||||
});
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
if (kind === 'sessions') {
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.fill('old');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.press('Enter');
|
||||
} else await page.getByRole('button', { name: /Race user 0/ }).click();
|
||||
await expect.poll(() => held).toBe(true);
|
||||
if (kind === 'sessions') {
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.fill('new');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.press('Enter');
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Race user 0/ }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await page.getByRole('button', { name: /Race user 1/ }).click();
|
||||
await expect(
|
||||
page.getByText('Current message', { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
release();
|
||||
await expect.poll(() => released).toBe(true);
|
||||
// Allow the released HTTP response and React's queued update to settle.
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
if (kind !== 'sessions') {
|
||||
await expect(
|
||||
page.getByText('Current message', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('current_tool', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Old message', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText('old_tool', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('bot session monitor tool timeline', () => {
|
||||
test('isolates messages and analysis for two bots sharing a raw session id', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const requests: Array<{ bot: string; path: string }> = [];
|
||||
await page.route('**/api/v1/monitoring/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const selectedBot = url.searchParams.get('botId');
|
||||
if (
|
||||
!url.pathname.endsWith('/sessions') &&
|
||||
!url.pathname.endsWith('/messages') &&
|
||||
!url.pathname.endsWith('/analysis')
|
||||
) {
|
||||
return route.fallback();
|
||||
}
|
||||
expect(['bot-shared-a', 'bot-shared-b']).toContain(selectedBot);
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
expect(route.request().headers()['x-workspace-id']).toBe(
|
||||
'workspace-playwright',
|
||||
);
|
||||
requests.push({ bot: selectedBot!, path: url.pathname });
|
||||
const shared = {
|
||||
session_id: sessionId,
|
||||
bot_id: selectedBot,
|
||||
bot_name: selectedBot,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 1,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
platform: 'person',
|
||||
user_id: 'shared-user',
|
||||
user_name: 'Shared User',
|
||||
};
|
||||
const data = url.pathname.endsWith('/sessions')
|
||||
? { sessions: [shared], total: 1 }
|
||||
: url.pathname.endsWith('/messages')
|
||||
? {
|
||||
messages: [
|
||||
{
|
||||
...sessionMessage(
|
||||
'shared-message',
|
||||
'user',
|
||||
0,
|
||||
`Message for ${selectedBot}`,
|
||||
),
|
||||
bot_id: selectedBot,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}
|
||||
: {
|
||||
session_id: sessionId,
|
||||
found: true,
|
||||
tool_calls: [
|
||||
{
|
||||
...toolCall('shared-tool', 1, `tool_${selectedBot}`, 40),
|
||||
bot_id: selectedBot,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (url.pathname.endsWith('/messages'))
|
||||
expect(url.searchParams.get('sessionId')).toBe(sessionId);
|
||||
if (url.pathname.endsWith('/analysis'))
|
||||
expect(decodeURIComponent(url.pathname)).toContain(
|
||||
`/sessions/${sessionId}/analysis`,
|
||||
);
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
for (const selectedBot of ['bot-shared-a', 'bot-shared-b']) {
|
||||
await page.goto(`/home/bots?id=${selectedBot}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await page.getByRole('button', { name: /Shared User/ }).click();
|
||||
await expect(
|
||||
page.getByText(`Message for ${selectedBot}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`tool_${selectedBot}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
const otherBot =
|
||||
selectedBot === 'bot-shared-a' ? 'bot-shared-b' : 'bot-shared-a';
|
||||
await expect(
|
||||
page.getByText(`Message for ${otherBot}`, { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(`tool_${otherBot}`, { exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.bot === selectedBot && request.path.endsWith('/messages'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.bot === selectedBot && request.path.endsWith('/analysis'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
test('renders tool calls as left-side agent events interleaved with messages', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -117,11 +491,41 @@ test.describe('bot session monitor tool timeline', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const monitoringRequests: import('@playwright/test').Request[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/v1/monitoring/'))
|
||||
monitoringRequests.push(request);
|
||||
});
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await page.getByRole('button', { name: /Timeline User/ }).click();
|
||||
|
||||
await expect(page.getByText('Need a timeline check')).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
monitoringRequests.some((request) =>
|
||||
request.url().includes('/analysis?'),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
for (const request of monitoringRequests.filter((request) =>
|
||||
/\/messages\?|\/analysis\?/.test(request.url()),
|
||||
)) {
|
||||
const url = new URL(request.url());
|
||||
expect(url.searchParams.get('botId')).toBe(botId);
|
||||
if (url.pathname.endsWith('/analysis')) {
|
||||
expect(url.searchParams.get('startTime')).toBe(at(0));
|
||||
expect(url.searchParams.get('endTime')).toBe(at(4));
|
||||
}
|
||||
expect(request.headers().authorization).toBe('Bearer playwright-token');
|
||||
expect(request.headers()['x-workspace-id']).toBe('workspace-playwright');
|
||||
if (url.pathname.endsWith('/messages'))
|
||||
expect(url.searchParams.get('sessionId')).toBe(sessionId);
|
||||
else
|
||||
expect(decodeURIComponent(url.pathname)).toContain(
|
||||
`/sessions/${sessionId}/analysis`,
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
page.getByText('repo_file_read', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test, Route } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
|
||||
@@ -271,7 +271,198 @@ function rawMonitoringData() {
|
||||
};
|
||||
}
|
||||
|
||||
async function respond(route: Route, label: string) {
|
||||
const data = rawMonitoringData();
|
||||
data.messages = [rawMessage(message(label, 'user', 10, label))];
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
}
|
||||
|
||||
test.describe('monitoring request contracts', () => {
|
||||
test('shows failures instead of empty success and retries with auth and Workspace headers', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let failing = true;
|
||||
await page.route('**/api/v1/monitoring/data?*', async (route) => {
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
expect(route.request().headers()['x-workspace-id']).toBe(
|
||||
'workspace-playwright',
|
||||
);
|
||||
if (failing)
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'fixture database unavailable' },
|
||||
});
|
||||
else await respond(route, 'Recovered monitoring');
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Failed to load monitoring data',
|
||||
);
|
||||
await expect(page.getByText('No message records')).toHaveCount(0);
|
||||
failing = false;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('Recovered monitoring', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('latest filter request wins over delayed data and delayed failures', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const pending: Route[] = [];
|
||||
await page.route('**/api/v1/monitoring/data?*', (route) => {
|
||||
pending.push(route);
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect.poll(() => pending.length).toBe(2);
|
||||
await page.getByRole('combobox').last().click();
|
||||
await page.getByRole('option', { name: /Last 7 days/i }).click();
|
||||
await expect.poll(() => pending.length).toBe(3);
|
||||
await respond(pending[2], 'Latest filter data');
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
).toBeVisible();
|
||||
await respond(pending[0], 'Obsolete filter data');
|
||||
await respond(pending[1], 'Obsolete filter data');
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Refresh Data', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => pending.length).toBe(4);
|
||||
await expect(
|
||||
page.getByText('Obsolete filter data', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole('combobox').last().click();
|
||||
await page.getByRole('option', { name: /Last 24 hours/i }).click();
|
||||
await expect.poll(() => pending.length).toBe(5);
|
||||
await respond(pending[4], 'Current result');
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
).toBeVisible();
|
||||
await pending[3].fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'old failure' },
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('uses aggregate traffic rather than the sparse record page and discloses truncation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const data = rawMonitoringData();
|
||||
data.totalCount.messages = 125;
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: {
|
||||
...data,
|
||||
traffic: {
|
||||
bucket: 'hour',
|
||||
truncated: true,
|
||||
points: [
|
||||
{ timestamp: time(0).toISOString(), messages: 125, llm_calls: 77 },
|
||||
{ timestamp: time(1).toISOString(), messages: 0, llm_calls: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Traffic range truncated. Choose a shorter time range.'),
|
||||
).toBeVisible();
|
||||
const chart = page.locator('.recharts-wrapper');
|
||||
await expect(chart).toHaveCount(1);
|
||||
await chart
|
||||
.locator(':scope > .recharts-surface')
|
||||
.hover({ position: { x: 70, y: 100 } });
|
||||
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
|
||||
'125',
|
||||
);
|
||||
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
|
||||
'77',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not invent traffic totals when aggregation is unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: rawMonitoringData(),
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByText('Traffic aggregation unavailable'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.recharts-wrapper')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('monitoring conversation turn grouping', () => {
|
||||
test('does not reassign explicitly linked activity outside the visible page', () => {
|
||||
const turns = buildConversationTurns(
|
||||
[message('visible', 'user', 10, 'Visible turn')],
|
||||
[llmCall('older-call', 11, 'off-page', 10, 5, 40)],
|
||||
[errorLog('older-error', 11, 'off-page')],
|
||||
[toolCall('older-tool', 11, 'off-page', 'search', 40)],
|
||||
);
|
||||
expect(turns[0].llmCalls).toEqual([]);
|
||||
expect(turns[0].toolCalls).toEqual([]);
|
||||
expect(turns[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not assign unlinked activity before the first visible turn', () => {
|
||||
const turns = buildConversationTurns(
|
||||
[message('visible', 'user', 10, 'Visible turn')],
|
||||
[llmCall('older-call', 1, undefined, 10, 5, 40)],
|
||||
[{ ...errorLog('older-error', 1, ''), messageId: undefined }],
|
||||
[toolCall('older-tool', 1, undefined, 'search', 40)],
|
||||
);
|
||||
expect(turns[0].llmCalls).toEqual([]);
|
||||
expect(turns[0].toolCalls).toEqual([]);
|
||||
expect(turns[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('isolates same-session messages and activity by bot identity', () => {
|
||||
const first = message('first', 'user', 1, 'Bot one');
|
||||
const other = {
|
||||
...message('other', 'user', 2, 'Bot two'),
|
||||
botId: 'other-bot',
|
||||
};
|
||||
const reply = message('reply', 'assistant', 3, 'Bot one reply');
|
||||
const turns = buildConversationTurns(
|
||||
[first, other, reply],
|
||||
[llmCall('call', 3, undefined, 10, 5, 40)],
|
||||
[errorLog('error', 3, first.id)],
|
||||
[toolCall('tool', 3, undefined, 'search', 40)],
|
||||
);
|
||||
const own = turns.find((turn) => turn.id === first.id)!;
|
||||
expect(own.assistantMessages.map((item) => item.id)).toEqual(['reply']);
|
||||
expect(own.llmCalls.map((item) => item.id)).toEqual(['call']);
|
||||
expect(own.toolCalls.map((item) => item.id)).toEqual(['tool']);
|
||||
expect(turns.find((turn) => turn.id === other.id)?.totalTokens).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps a single user message as one observable turn', () => {
|
||||
const userOnly = message(
|
||||
'single-user-only',
|
||||
|
||||
@@ -134,6 +134,22 @@ test('session tool calls are bounded to the visible message page', () => {
|
||||
const monitor = read(
|
||||
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||
);
|
||||
includes(monitor, "analysisParams.set('startTime'", 'analysis page start');
|
||||
includes(monitor, "analysisParams.set('endTime'", 'analysis page end');
|
||||
includes(monitor, 'startTime: sorted[0]?.timestamp', 'analysis page start');
|
||||
includes(
|
||||
monitor,
|
||||
'endTime: sorted[sorted.length - 1]?.timestamp',
|
||||
'analysis page end',
|
||||
);
|
||||
includes(monitor, 'sessionId, botId, {', 'bot-scoped analysis');
|
||||
const client = read('src/app/infra/http/BackendClient.ts');
|
||||
includes(
|
||||
client,
|
||||
"queryParams.set('startTime', options.startTime)",
|
||||
'analysis start query',
|
||||
);
|
||||
includes(
|
||||
client,
|
||||
"queryParams.set('endTime', options.endTime)",
|
||||
'analysis end query',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user