mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
feat(monitoring): unify execution views and trace tool calls
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MessageContentRenderer } from './MessageContentRenderer';
|
||||
import {
|
||||
ConversationTurn,
|
||||
@@ -507,10 +508,11 @@ export function ConversationTurnList({
|
||||
key={call.id}
|
||||
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
|
||||
'flex h-auto w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left',
|
||||
hasToolDetails &&
|
||||
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
@@ -556,7 +558,7 @@ export function ConversationTurnList({
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{hasToolDetails && expandedToolCall && (
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
Layers,
|
||||
ThumbsUp,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -182,26 +183,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('monitoring.export.exporting')}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Heart,
|
||||
Smile,
|
||||
} from 'lucide-react';
|
||||
import { MetricCard } from './overview-cards/MetricCard';
|
||||
|
||||
interface FeedbackCardProps {
|
||||
title: string;
|
||||
@@ -23,6 +24,18 @@ interface FeedbackCardProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// The variant only tints the icon tile; the card surface stays neutral so the
|
||||
// feedback row matches the token-monitoring cards.
|
||||
const variantAccents: Record<
|
||||
NonNullable<FeedbackCardProps['variant']>,
|
||||
string
|
||||
> = {
|
||||
default: '#64748b',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
};
|
||||
|
||||
export function FeedbackCard({
|
||||
title,
|
||||
value,
|
||||
@@ -32,89 +45,37 @@ export function FeedbackCard({
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}: FeedbackCardProps) {
|
||||
const variantStyles = {
|
||||
default: 'bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700',
|
||||
success:
|
||||
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
warning:
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
|
||||
danger: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
default: 'text-gray-500 dark:text-gray-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
warning: 'text-yellow-500 dark:text-yellow-400',
|
||||
danger: 'text-red-500 dark:text-red-400',
|
||||
};
|
||||
|
||||
const trendStyles = {
|
||||
up: 'text-green-500',
|
||||
down: 'text-red-500',
|
||||
neutral: 'text-gray-500',
|
||||
down: 'text-destructive',
|
||||
neutral: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles.default} animate-pulse`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2" />
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-1" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles[variant]}`}
|
||||
<MetricCard
|
||||
label={title}
|
||||
value={value}
|
||||
hint={subtitle}
|
||||
icon={icon}
|
||||
accent={variantAccents[variant]}
|
||||
loading={loading}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{trend && (
|
||||
<div
|
||||
className={`flex items-center mt-2 text-sm ${trendStyles[trend.direction]}`}
|
||||
>
|
||||
{trend.direction === 'up' && (
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{trend.direction === 'down' && (
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{trend.direction === 'neutral' && (
|
||||
<Minus className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
<span>
|
||||
{trend.value > 0 ? '+' : ''}
|
||||
{trend.value}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{trend && (
|
||||
<div
|
||||
className={`p-3 rounded-lg bg-gray-100 dark:bg-gray-800 ${iconStyles[variant]}`}
|
||||
className={`flex items-center text-sm ${trendStyles[trend.direction]}`}
|
||||
>
|
||||
{icon}
|
||||
{trend.direction === 'up' && <TrendingUp className="mr-1 h-4 w-4" />}
|
||||
{trend.direction === 'down' && (
|
||||
<TrendingDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{trend.direction === 'neutral' && <Minus className="mr-1 h-4 w-4" />}
|
||||
<span>
|
||||
{trend.value > 0 ? '+' : ''}
|
||||
{trend.value}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,35 +96,37 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
|
||||
{
|
||||
title: t('monitoring.feedback.totalFeedback'),
|
||||
value: stats?.totalFeedback ?? 0,
|
||||
icon: <Heart className="w-6 h-6" />,
|
||||
icon: <Heart className="h-4 w-4" />,
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalLikes'),
|
||||
value: stats?.totalLikes ?? 0,
|
||||
icon: <ThumbsUp className="w-6 h-6" />,
|
||||
icon: <ThumbsUp className="h-4 w-4" />,
|
||||
variant: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalDislikes'),
|
||||
value: stats?.totalDislikes ?? 0,
|
||||
icon: <ThumbsDown className="w-6 h-6" />,
|
||||
icon: <ThumbsDown className="h-4 w-4" />,
|
||||
variant: 'danger' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.satisfactionRate'),
|
||||
value: stats ? `${stats.satisfactionRate}%` : '0%',
|
||||
icon: <Smile className="w-6 h-6" />,
|
||||
icon: <Smile className="h-4 w-4" />,
|
||||
// The rate is the one feedback metric that is good or bad on its own, so
|
||||
// its tile keeps encoding the verdict (as it did before the restyle).
|
||||
variant: (stats && stats.satisfactionRate >= 80
|
||||
? 'success'
|
||||
: stats && stats.satisfactionRate >= 50
|
||||
? 'warning'
|
||||
: 'danger') as 'default' | 'success' | 'warning' | 'danger',
|
||||
: 'danger') as 'success' | 'warning' | 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card, index) => (
|
||||
<FeedbackCard
|
||||
key={index}
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { FeedbackRecord } from '../types/monitoring';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { TabState } from './TabState';
|
||||
|
||||
interface FeedbackListProps {
|
||||
feedback: FeedbackRecord[];
|
||||
@@ -31,33 +33,25 @@ export function FeedbackList({
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
return <TabState loading rows={6} />;
|
||||
}
|
||||
|
||||
if (!feedback || feedback.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-base font-medium mb-2">
|
||||
{t('monitoring.feedback.noFeedback')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('monitoring.feedback.noFeedbackDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<TabState
|
||||
icon={<Heart className="w-16 h-16 text-muted-foreground/50" />}
|
||||
title={t('monitoring.feedback.noFeedback')}
|
||||
hint={t('monitoring.feedback.noFeedbackDescription')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{feedback.map((item) => (
|
||||
<div
|
||||
<Card
|
||||
key={item.id}
|
||||
className={`border rounded-xl overflow-hidden hover:shadow-md transition-all duration-200 ${
|
||||
className={`gap-0 py-0 overflow-hidden hover:shadow-md transition-all duration-200 ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
@@ -105,16 +99,19 @@ export function FeedbackList({
|
||||
</span>
|
||||
{item.botName && (
|
||||
<>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.botName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.platform && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-muted text-muted-foreground"
|
||||
>
|
||||
{item.platform}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{item.streamId && onViewMessage && (
|
||||
<Button
|
||||
@@ -133,7 +130,7 @@ export function FeedbackList({
|
||||
</div>
|
||||
|
||||
{item.feedbackContent && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
)}
|
||||
@@ -142,12 +139,13 @@ export function FeedbackList({
|
||||
item.inaccurateReasons.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{item.inaccurateReasons.map((reason, idx) => (
|
||||
<span
|
||||
<Badge
|
||||
key={idx}
|
||||
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
|
||||
variant="outline"
|
||||
className="bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{reason}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -156,7 +154,7 @@ export function FeedbackList({
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{item.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -166,75 +164,75 @@ export function FeedbackList({
|
||||
{/* Expanded Details */}
|
||||
{expandedId === item.id && (
|
||||
<div
|
||||
className={`border-t p-5 bg-white dark:bg-gray-900 ${
|
||||
className={`border-t p-5 bg-background ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
|
||||
{/* Context Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.feedback.contextInfo')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
{item.botName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.botName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.pipelineName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.sessionId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.userId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.feedback.userId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.userId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.messageId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.feedback.messageId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.messageId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.streamId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.feedback.streamId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.streamId}
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,11 +242,11 @@ export function FeedbackList({
|
||||
|
||||
{/* Feedback Content */}
|
||||
{item.feedbackContent && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.feedback.feedbackContent')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
</div>
|
||||
@@ -256,7 +254,7 @@ export function FeedbackList({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Voice,
|
||||
Quote,
|
||||
} from '@/app/infra/entities/message';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';
|
||||
|
||||
interface MessageContentRendererProps {
|
||||
@@ -48,23 +49,23 @@ export function MessageContentRenderer({
|
||||
const displayName =
|
||||
atComponent.display || atComponent.target?.toString() || '';
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
>
|
||||
@{displayName}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'AtAll':
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
>
|
||||
@All
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
case 'Image': {
|
||||
@@ -73,12 +74,12 @@ export function MessageContentRenderer({
|
||||
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[Image]
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,41 +102,41 @@ export function MessageContentRenderer({
|
||||
case 'File': {
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm [&>svg]:size-3.5"
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 mr-1" />
|
||||
<Paperclip className="mr-1" />
|
||||
{file.name || 'File'}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Voice': {
|
||||
const voice = component as Voice;
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm [&>svg]:size-3.5"
|
||||
>
|
||||
<AudioLines className="w-3.5 h-3.5 mr-1" />
|
||||
<AudioLines className="mr-1" />
|
||||
Voice{voice.length ? ` ${voice.length}s` : ''}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Quote': {
|
||||
const quote = component as Quote;
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-l-muted-foreground/50"
|
||||
>
|
||||
{quote.origin
|
||||
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
|
||||
.map((c) => (c as MessageChainComponent as Plain).text)
|
||||
.join('') || '[Quote]'}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,12 +145,12 @@ export function MessageContentRenderer({
|
||||
|
||||
default:
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[{component.type}]
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info, Clock, AlertCircle, Braces } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageDetails } from '../types/monitoring';
|
||||
|
||||
interface MessageDetailsCardProps {
|
||||
@@ -24,7 +27,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
|
||||
{/* Context Info Section */}
|
||||
{details.message && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
@@ -79,12 +82,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LLM Calls Section */}
|
||||
{details.llmCalls && details.llmCalls.length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
|
||||
@@ -127,15 +130,16 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
<span className="font-medium text-foreground">
|
||||
#{index + 1} {call.modelName}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 text-xs px-2 py-0.5 rounded ${
|
||||
<Badge
|
||||
className={cn(
|
||||
'ml-2',
|
||||
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'
|
||||
}`}
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{call.duration}ms
|
||||
@@ -163,12 +167,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Errors Section */}
|
||||
{details.errors && details.errors.length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.errors.title')} ({details.errors.length})
|
||||
@@ -198,12 +202,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Query Variables Section */}
|
||||
{queryVariables && Object.keys(queryVariables).length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Braces className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.queryVariables.title')}
|
||||
@@ -233,7 +237,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No data message */}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface TabStateProps {
|
||||
/** Skeleton rows while the tab's data is in flight. */
|
||||
loading?: boolean;
|
||||
/** Number of skeleton rows; the rhythm matches the loaded content. */
|
||||
rows?: number;
|
||||
/** Icon for the empty state, in the same muted tone as the copy. */
|
||||
icon?: React.ReactNode;
|
||||
/** Empty-state headline. */
|
||||
title?: string;
|
||||
/** Optional second line explaining what would fill the tab. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one loading/empty treatment every detail tab uses.
|
||||
*
|
||||
* Tabs are peers on one fixed-height surface, so they must not each invent their
|
||||
* own placeholder shape: a reader switching tabs sees the same skeleton rhythm
|
||||
* and the same centred empty card regardless of which record type is empty.
|
||||
*/
|
||||
export function TabState({
|
||||
loading,
|
||||
rows = 6,
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: TabStateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" role="status" aria-busy="true">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
{icon}
|
||||
{title && <div className="text-sm">{title}</div>}
|
||||
{hint && <div className="text-xs">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,19 @@ import {
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MetricCard } from './overview-cards/MetricCard';
|
||||
import { Alert, AlertTitle } from '@/components/ui/alert';
|
||||
import { TabState } from './TabState';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getErrorMessage } from '../utils';
|
||||
|
||||
@@ -89,41 +102,6 @@ const TOOLTIP_STYLE: React.CSSProperties = {
|
||||
color: 'var(--foreground)',
|
||||
};
|
||||
|
||||
function MetricTile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<span
|
||||
className="flex items-center justify-center h-7 w-7 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
|
||||
color: accent || 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokenMonitoring({
|
||||
botIds,
|
||||
pipelineIds,
|
||||
@@ -175,38 +153,23 @@ export default function TokenMonitoring({
|
||||
}, [stats]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-card rounded-xl border p-4 h-24 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-6 h-[320px] animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
return <TabState loading rows={6} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6 text-sm text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('monitoring.tokens.loadError', { error })}
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('monitoring.tokens.loadError', { error })}</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats || !stats.summary || stats.summary.total_calls === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<div className="h-[260px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<Coins className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">{t('monitoring.tokens.noData')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<TabState
|
||||
icon={<Coins className="h-12 w-12" />}
|
||||
title={t('monitoring.tokens.noData')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,70 +192,69 @@ export default function TokenMonitoring({
|
||||
|
||||
{/* Summary tiles */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<Coins className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.totalTokens')}
|
||||
value={formatNumber(summary.total_tokens)}
|
||||
sub={t('monitoring.tokens.acrossCalls', {
|
||||
hint={t('monitoring.tokens.acrossCalls', {
|
||||
count: summary.total_calls,
|
||||
})}
|
||||
accent="#8b5cf6"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<ArrowDownToLine className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.inputTokens')}
|
||||
value={formatNumber(summary.total_input_tokens)}
|
||||
accent="#3b82f6"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<ArrowUpFromLine className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.outputTokens')}
|
||||
value={formatNumber(summary.total_output_tokens)}
|
||||
accent="#10b981"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.avgPerCall')}
|
||||
value={formatNumber(summary.avg_tokens_per_call)}
|
||||
accent="#f59e0b"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<Gauge className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.throughput')}
|
||||
value={`${summary.avg_tokens_per_second}`}
|
||||
sub={t('monitoring.tokens.tokensPerSec')}
|
||||
hint={t('monitoring.tokens.tokensPerSec')}
|
||||
accent="#06b6d4"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.errorCalls')}
|
||||
value={`${summary.error_calls}`}
|
||||
sub={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
|
||||
hint={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
|
||||
accent="#ef4444"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token usage over time */}
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t('monitoring.tokens.usageOverTime')}
|
||||
</h3>
|
||||
<div className="inline-flex rounded-lg border p-0.5 text-sm">
|
||||
{(['hour', 'day'] as const).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBucket(b)}
|
||||
className={`px-3 py-1 rounded-md transition-colors ${
|
||||
bucket === b
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`monitoring.tokens.bucket.${b}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs
|
||||
value={bucket}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'hour' || value === 'day') setBucket(value);
|
||||
}}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
{(['hour', 'day'] as const).map((b) => (
|
||||
<TabsTrigger key={b} value={b} className="text-xs">
|
||||
{t(`monitoring.tokens.bucket.${b}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -372,93 +334,86 @@ export default function TokenMonitoring({
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Per-model breakdown */}
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-base font-semibold text-foreground mb-4">
|
||||
{t('monitoring.tokens.byModel')}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground border-b">
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t('monitoring.tokens.model')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.calls')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.inputTokens')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.outputTokens')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.totalTokens')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.avgPerCall')}
|
||||
</th>
|
||||
<th className="py-2 pl-4 font-medium text-right">
|
||||
{t('monitoring.tokens.avgLatency')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{by_model.map((m) => {
|
||||
const share =
|
||||
summary.total_tokens > 0
|
||||
? (m.total_tokens / summary.total_tokens) * 100
|
||||
: 0;
|
||||
return (
|
||||
<tr
|
||||
key={m.model_name}
|
||||
className="border-b last:border-0 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 pr-4">
|
||||
<div className="font-medium text-foreground">
|
||||
{m.model_name}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-500"
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{m.calls}
|
||||
{m.error_calls > 0 && (
|
||||
<span className="text-destructive">
|
||||
{' '}
|
||||
({m.error_calls}✕)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatNumber(m.input_tokens)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatNumber(m.output_tokens)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums font-medium">
|
||||
{formatNumber(m.total_tokens)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatNumber(m.avg_tokens_per_call)}
|
||||
</td>
|
||||
<td className="py-2.5 pl-4 text-right tabular-nums">
|
||||
{m.avg_duration_ms}ms
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('monitoring.tokens.model')}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.calls')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.inputTokens')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.outputTokens')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.totalTokens')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.avgPerCall')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.avgLatency')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{by_model.map((m) => {
|
||||
const share =
|
||||
summary.total_tokens > 0
|
||||
? (m.total_tokens / summary.total_tokens) * 100
|
||||
: 0;
|
||||
return (
|
||||
<TableRow key={m.model_name}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-foreground">
|
||||
{m.model_name}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-500"
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{m.calls}
|
||||
{m.error_calls > 0 && (
|
||||
<span className="text-destructive">
|
||||
{' '}
|
||||
({m.error_calls}✕)
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(m.input_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(m.output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{formatNumber(m.total_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(m.avg_tokens_per_call)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{m.avg_duration_ms}ms
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import type {
|
||||
ExecutionCallRow,
|
||||
ExecutionDetail,
|
||||
ExecutionRow,
|
||||
} from '@/app/infra/entities/api/monitoring-executions';
|
||||
import AgentExecutionTrace from '@/app/home/agents/components/AgentExecutionTrace';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { formatDateTime } from '../../utils/dateUtils';
|
||||
import {
|
||||
executionKindLabel,
|
||||
executionProcessorLabel,
|
||||
executionStatusLabel,
|
||||
executionStatusVariant,
|
||||
executionTargetLabel,
|
||||
} from './ExecutionTable';
|
||||
|
||||
interface ExecutionDetailSheetProps {
|
||||
row: ExecutionRow | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Walk parsed JSON, collecting every `"text"` value (OpenAI-style content). */
|
||||
function collectText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collectText).filter(Boolean).join(' ');
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
return Object.values(record).map(collectText).filter(Boolean).join(' ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function messagePreview(raw: string | null | undefined): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return collectText(JSON.parse(raw)).trim() || raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function callTitle(call: ExecutionCallRow, index: number): string {
|
||||
const candidates = [
|
||||
call.model_name,
|
||||
call.model,
|
||||
call.name,
|
||||
call.tool_name,
|
||||
call.function,
|
||||
call.error_type,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate) return candidate;
|
||||
}
|
||||
return typeof call.id === 'string' && call.id ? call.id : `#${index + 1}`;
|
||||
}
|
||||
|
||||
export default function ExecutionDetailSheet({
|
||||
row,
|
||||
onClose,
|
||||
}: ExecutionDetailSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<ExecutionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!row) {
|
||||
setDetail(null);
|
||||
setError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const requestId = requestRef.current + 1;
|
||||
requestRef.current = requestId;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setDetail(null);
|
||||
backendClient
|
||||
.getExecutionDetail(row.source, row.id)
|
||||
.then((result) => {
|
||||
if (requestRef.current !== requestId) return;
|
||||
setDetail(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestRef.current !== requestId) return;
|
||||
setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestRef.current === requestId) setLoading(false);
|
||||
});
|
||||
}, [row]);
|
||||
|
||||
const isAgent =
|
||||
detail != null ? detail.source === 'agent' : row?.source === 'agent';
|
||||
|
||||
const metadata: { label: string; value: string }[] = [];
|
||||
if (row) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.executionId'),
|
||||
value: row.id,
|
||||
});
|
||||
if (row.event_id) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.eventId'),
|
||||
value: row.event_id,
|
||||
});
|
||||
}
|
||||
if (row.runner_id) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.runner'),
|
||||
value: row.runner_id,
|
||||
});
|
||||
}
|
||||
if (row.queue_name) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.queue'),
|
||||
value: row.queue_name,
|
||||
});
|
||||
}
|
||||
if (row.created_at_ms != null) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.created'),
|
||||
value: formatDateTime(new Date(row.created_at_ms)),
|
||||
});
|
||||
}
|
||||
if (row.duration_ms != null) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.duration'),
|
||||
value: formatRunDuration(row.duration_ms),
|
||||
});
|
||||
}
|
||||
if (isAgent && row.usage?.total_tokens != null) {
|
||||
metadata.push({
|
||||
label: t('monitoring.execution.detail.usage'),
|
||||
value: row.usage.total_tokens.toLocaleString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const objectHref = row
|
||||
? row.target_kind === 'agent' && row.target_id
|
||||
? `/home/agents?id=${encodeURIComponent(row.target_id)}`
|
||||
: row.target_kind === 'pipeline' && row.target_id
|
||||
? `/home/pipelines?id=${encodeURIComponent(row.target_id)}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const renderCalls = (title: string, calls: ExecutionCallRow[]) => (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{calls.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('monitoring.execution.detail.noEvents')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{calls.map((call, index) => {
|
||||
const duration =
|
||||
typeof call.duration === 'number' ? call.duration : null;
|
||||
return (
|
||||
<div
|
||||
key={`${title}-${call.id}-${index}`}
|
||||
className="flex items-start justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{callTitle(call, index)}</div>
|
||||
{call.error_message && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{call.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{call.status && (
|
||||
<Badge
|
||||
variant={executionStatusVariant(
|
||||
String(call.status).toLowerCase(),
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="w-16 text-right tabular-nums text-muted-foreground">
|
||||
{duration == null ? '—' : formatRunDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={row != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" className="w-full p-0 sm:max-w-2xl">
|
||||
<SheetDescription className="sr-only">
|
||||
{t('monitoring.execution.detail.a11yDescription')}
|
||||
</SheetDescription>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-6">
|
||||
{row && (
|
||||
<>
|
||||
<SheetHeader className="space-y-3">
|
||||
<SheetTitle className="pr-8 text-left">
|
||||
{executionProcessorLabel(row)}
|
||||
</SheetTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('monitoring.execution.triggeredBy', {
|
||||
trigger: executionTargetLabel(row, t),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline">
|
||||
{executionKindLabel(row, t)}
|
||||
</Badge>
|
||||
<Badge variant={executionStatusVariant(row.status_group)}>
|
||||
{executionStatusLabel(row, t)}
|
||||
</Badge>
|
||||
{row.debug && (
|
||||
<Badge variant="secondary">
|
||||
{t('monitoring.execution.debug')}
|
||||
</Badge>
|
||||
)}
|
||||
{objectHref && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto h-7"
|
||||
>
|
||||
<Link to={objectHref}>
|
||||
<ExternalLink className="mr-1 h-3.5 w-3.5" />
|
||||
{t('monitoring.execution.detail.openObject')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{metadata.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-0.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="truncate text-sm font-medium">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isAgent ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('monitoring.execution.detail.timeline')}
|
||||
</h3>
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
detail?.source === 'agent' &&
|
||||
(detail.events.length > 0 ? (
|
||||
<AgentExecutionTrace
|
||||
events={detail.events}
|
||||
finished={row.status_group !== 'running'}
|
||||
toolLabels={{}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('monitoring.execution.detail.noEvents')}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && detail?.source === 'pipeline' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('monitoring.execution.detail.query')}
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap break-words rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||
{messagePreview(detail.message.message_content) ||
|
||||
'—'}
|
||||
</p>
|
||||
</div>
|
||||
{renderCalls(
|
||||
t('monitoring.execution.detail.modelCalls'),
|
||||
detail.llm_calls,
|
||||
)}
|
||||
{renderCalls(
|
||||
t('monitoring.execution.detail.toolCalls'),
|
||||
detail.tool_calls,
|
||||
)}
|
||||
{renderCalls(
|
||||
t('monitoring.execution.detail.errors'),
|
||||
detail.errors,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { formatDateTime } from '../../utils/dateUtils';
|
||||
|
||||
interface ExecutionTableProps {
|
||||
rows: ExecutionRow[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (row: ExecutionRow) => void;
|
||||
}
|
||||
|
||||
const STATUS_GROUPS = [
|
||||
'completed',
|
||||
'failed',
|
||||
'running',
|
||||
'queued',
|
||||
'cancelled',
|
||||
'ignored',
|
||||
];
|
||||
|
||||
export function executionStatusLabel(row: ExecutionRow, t: TFunction): string {
|
||||
if (STATUS_GROUPS.includes(row.status_group)) {
|
||||
return t(`monitoring.execution.status.${row.status_group}`);
|
||||
}
|
||||
return row.status;
|
||||
}
|
||||
|
||||
export function executionStatusVariant(
|
||||
statusGroup: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (statusGroup === 'failed') return 'destructive';
|
||||
if (statusGroup === 'ignored' || statusGroup === 'queued') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
const KIND_GROUPS = ['agent', 'pipeline', 'processor'];
|
||||
|
||||
export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
|
||||
if (KIND_GROUPS.includes(row.target_kind)) {
|
||||
return t(`monitoring.execution.kind.${row.target_kind}`);
|
||||
}
|
||||
return row.target_kind;
|
||||
}
|
||||
|
||||
export function executionTargetLabel(row: ExecutionRow, t: TFunction): string {
|
||||
if (row.target_kind === 'agent') {
|
||||
return eventPatternLabel(row.title, t);
|
||||
}
|
||||
const title = row.title ?? '';
|
||||
return title.length > 48 ? `${title.slice(0, 48)}…` : title;
|
||||
}
|
||||
|
||||
// The processor the row ran: the Agent or Pipeline that handled it. A run whose
|
||||
// Agent was deleted still names the Runner that executed it.
|
||||
export function executionProcessorLabel(row: ExecutionRow): string {
|
||||
return row.target_name || row.runner_id || row.target_id || '—';
|
||||
}
|
||||
|
||||
export default function ExecutionTable({
|
||||
rows,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: ExecutionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
// The page hands this table a fixed-height region: the table's own scroll
|
||||
// container takes that height so the header stays sticky while rows scroll
|
||||
// under it.
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border">
|
||||
<Table containerClassName="min-h-0 flex-1 overflow-y-auto">
|
||||
<TableHeader className="bg-card sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead>{t('monitoring.execution.columns.time')}</TableHead>
|
||||
<TableHead>{t('monitoring.execution.columns.target')}</TableHead>
|
||||
<TableHead>{t('monitoring.execution.columns.status')}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.execution.columns.duration')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.execution.columns.tokens')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const selected = selectedId === row.id;
|
||||
return (
|
||||
<TableRow
|
||||
key={`${row.source}-${row.id}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
data-state={selected ? 'selected' : undefined}
|
||||
className="cursor-pointer data-[state=selected]:bg-muted"
|
||||
onClick={() => onSelect(row)}
|
||||
onKeyDown={(
|
||||
event: React.KeyboardEvent<HTMLTableRowElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(row);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{row.created_at_ms == null
|
||||
? '—'
|
||||
: formatDateTime(new Date(row.created_at_ms))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{executionKindLabel(row, t)}
|
||||
</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{executionProcessorLabel(row)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.triggeredBy', {
|
||||
trigger: executionTargetLabel(row, t),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant={executionStatusVariant(row.status_group)}>
|
||||
{executionStatusLabel(row, t)}
|
||||
</Badge>
|
||||
{row.debug && (
|
||||
<Badge variant="secondary">
|
||||
{t('monitoring.execution.debug')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.duration_ms == null
|
||||
? '—'
|
||||
: formatRunDuration(row.duration_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.usage?.total_tokens != null
|
||||
? row.usage.total_tokens.toLocaleString()
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { TimeRangeOption } from '../../types/monitoring';
|
||||
import type {
|
||||
ExecutionModeFilter,
|
||||
ExecutionSourceFilter,
|
||||
} from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
interface MonitoringFiltersProps {
|
||||
selectedBots: string[];
|
||||
@@ -17,8 +22,23 @@ interface MonitoringFiltersProps {
|
||||
onBotsChange: (bots: string[]) => void;
|
||||
onPipelinesChange: (pipelines: string[]) => void;
|
||||
onTimeRangeChange: (timeRange: TimeRangeOption) => void;
|
||||
source?: ExecutionSourceFilter;
|
||||
onSourceChange?: (value: ExecutionSourceFilter) => void;
|
||||
mode?: ExecutionModeFilter;
|
||||
onModeChange?: (value: ExecutionModeFilter) => void;
|
||||
statusGroup?: string;
|
||||
onStatusGroupChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_GROUP_OPTIONS = [
|
||||
'completed',
|
||||
'failed',
|
||||
'running',
|
||||
'queued',
|
||||
'cancelled',
|
||||
'ignored',
|
||||
];
|
||||
|
||||
interface Bot {
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -36,6 +56,12 @@ export default function MonitoringFilters({
|
||||
onBotsChange,
|
||||
onPipelinesChange,
|
||||
onTimeRangeChange,
|
||||
source,
|
||||
onSourceChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
statusGroup,
|
||||
onStatusGroupChange,
|
||||
}: MonitoringFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
@@ -110,11 +136,98 @@ export default function MonitoringFilters({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-6">
|
||||
{/* Source Filter */}
|
||||
{onSourceChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.execution.filters.source')}
|
||||
</Label>
|
||||
<Select
|
||||
value={source ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
onSourceChange(value as ExecutionSourceFilter)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.execution.filters.sourceAll')}
|
||||
</SelectItem>
|
||||
<SelectItem value="agent">
|
||||
{t('monitoring.execution.filters.sourceAgent')}
|
||||
</SelectItem>
|
||||
<SelectItem value="pipeline">
|
||||
{t('monitoring.execution.filters.sourcePipeline')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode Filter */}
|
||||
{onModeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.execution.filters.mode')}
|
||||
</Label>
|
||||
<Select
|
||||
value={mode ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
onModeChange(value as ExecutionModeFilter)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.execution.filters.modeAll')}
|
||||
</SelectItem>
|
||||
<SelectItem value="real">
|
||||
{t('monitoring.execution.filters.modeLive')}
|
||||
</SelectItem>
|
||||
<SelectItem value="debug">
|
||||
{t('monitoring.execution.filters.modeDebug')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
{onStatusGroupChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.execution.filters.status')}
|
||||
</Label>
|
||||
<Select
|
||||
value={statusGroup ?? 'all'}
|
||||
onValueChange={(value) => onStatusGroupChange(value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.execution.filters.statusAll')}
|
||||
</SelectItem>
|
||||
{STATUS_GROUP_OPTIONS.map((group) => (
|
||||
<SelectItem key={group} value={group}>
|
||||
{t(`monitoring.execution.status.${group}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
|
||||
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.filters.bot')}
|
||||
</label>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
|
||||
onValueChange={handleBotChange}
|
||||
@@ -144,9 +257,9 @@ export default function MonitoringFilters({
|
||||
|
||||
{/* Pipeline Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
|
||||
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.filters.pipeline')}
|
||||
</label>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
|
||||
onValueChange={handlePipelineChange}
|
||||
@@ -176,9 +289,9 @@ export default function MonitoringFilters({
|
||||
|
||||
{/* Time Range Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
|
||||
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.filters.timeRange')}
|
||||
</label>
|
||||
</Label>
|
||||
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[150px]">
|
||||
<SelectValue />
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Timer,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { ExecutionSummary } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface ExecutionOverviewCardsProps {
|
||||
summary: ExecutionSummary | null;
|
||||
loading?: boolean;
|
||||
/** Active bot conversation sessions from the message rollup endpoint. */
|
||||
activeSessions?: number | null;
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-bold tabular-nums text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExecutionOverviewCards({
|
||||
summary,
|
||||
loading,
|
||||
activeSessions,
|
||||
}: ExecutionOverviewCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
const overview = summary?.executions ?? null;
|
||||
const tokens = summary?.tokens ?? null;
|
||||
|
||||
// Bare cards, not a grid: the page owns the grid so every metric and the
|
||||
// runtime status card land in the same rows.
|
||||
return (
|
||||
<>
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.totalExecutions')}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
accent="#8b5cf6"
|
||||
loading={loading}
|
||||
value={(overview?.total ?? 0).toLocaleString()}
|
||||
hint={t('monitoring.execution.cards.bySource', {
|
||||
agent: (overview?.by_source.agent ?? 0).toLocaleString(),
|
||||
pipeline: (overview?.by_source.pipeline ?? 0).toLocaleString(),
|
||||
})}
|
||||
>
|
||||
{!!overview?.debug && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.cards.hiddenDebug', {
|
||||
count: overview.debug,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.successRate')}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
accent="#3b82f6"
|
||||
loading={loading}
|
||||
value={
|
||||
overview?.success_rate != null ? `${overview.success_rate}%` : '—'
|
||||
}
|
||||
hint={t('monitoring.execution.cards.denominator', {
|
||||
completed: (overview?.completed ?? 0).toLocaleString(),
|
||||
denominator: (overview?.denominator ?? 0).toLocaleString(),
|
||||
})}
|
||||
action={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('monitoring.execution.cards.successRate')}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{t('monitoring.execution.cards.successRateHint')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.running')}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
accent="#06b6d4"
|
||||
loading={loading}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.running')}
|
||||
value={(overview?.running ?? 0).toLocaleString()}
|
||||
/>
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.queued')}
|
||||
value={(overview?.queued ?? 0).toLocaleString()}
|
||||
/>
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.waiting')}
|
||||
value={(overview?.waiting ?? 0).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.failed')}
|
||||
icon={<TriangleAlert className="h-4 w-4" />}
|
||||
accent="#ef4444"
|
||||
loading={loading}
|
||||
value={
|
||||
<span className="flex items-center gap-3">
|
||||
{(overview?.failed ?? 0).toLocaleString()}
|
||||
<Badge variant="secondary">
|
||||
{t('monitoring.execution.cards.ignored')}{' '}
|
||||
{(overview?.ignored ?? 0).toLocaleString()}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.latency')}
|
||||
icon={<Timer className="h-4 w-4" />}
|
||||
accent="#f59e0b"
|
||||
loading={loading}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.p50')}
|
||||
value={
|
||||
overview?.p50_duration_ms != null
|
||||
? formatRunDuration(overview.p50_duration_ms)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.p95')}
|
||||
value={
|
||||
overview?.p95_duration_ms != null
|
||||
? formatRunDuration(overview.p95_duration_ms)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* The sample size explains the percentiles above, so it stays under
|
||||
them rather than in the card's hint slot. */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.cards.sample', {
|
||||
count: overview?.duration_sample ?? 0,
|
||||
})}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.tokens')}
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
accent="#10b981"
|
||||
loading={loading}
|
||||
value={(tokens?.total_tokens ?? 0).toLocaleString()}
|
||||
hint={t('monitoring.execution.cards.coverage', {
|
||||
withUsage: (tokens?.calls_with_usage ?? 0).toLocaleString(),
|
||||
calls: (tokens?.calls ?? 0).toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.activeSessions')}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
accent="#6366f1"
|
||||
loading={loading}
|
||||
value={(activeSessions ?? 0).toLocaleString()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,88 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
'use client';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
direction: 'up' | 'down';
|
||||
};
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface MetricCardProps {
|
||||
/** Small label, sitting next to the icon. */
|
||||
label: string;
|
||||
/** The card's headline value; a node so it can carry a badge. */
|
||||
value?: React.ReactNode;
|
||||
/** Icon of the tinted tile on the leading edge. */
|
||||
icon?: React.ReactNode;
|
||||
/** Muted line under the value. */
|
||||
hint?: React.ReactNode;
|
||||
/** Tile colour as a hex value; defaults to the muted token. */
|
||||
accent?: string;
|
||||
/** Rendered at the card's top-right (tooltip trigger, help dialog, badge). */
|
||||
action?: React.ReactNode;
|
||||
/** Extra body under the value, for composite cards (stat grids, status rows). */
|
||||
children?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MetricCard({
|
||||
title,
|
||||
/**
|
||||
* The dashboard's one metric card: tinted icon tile and label on the leading
|
||||
* edge, value, then a muted hint. Token, execution, feedback and runtime
|
||||
* counters all render through it, so their rows and internal spacing match
|
||||
* instead of each surface inventing its own card.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
hint,
|
||||
accent,
|
||||
action,
|
||||
children,
|
||||
loading,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-9 w-28 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-20 bg-muted animate-pulse rounded mt-2"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-all duration-300 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">{icon}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground mb-2">{value}</div>
|
||||
{trend && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Card className={cn('gap-3 p-4', className)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
trend.direction === 'up'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
|
||||
color: accent || 'var(--muted-foreground)',
|
||||
}}
|
||||
>
|
||||
{trend.direction === 'up' ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{Math.abs(trend.value)}%
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs previous period
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
) : null}
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{value != null && value !== '' && (
|
||||
<div className="text-2xl font-semibold tabular-nums text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
{hint != null && hint !== '' && (
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricCard;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
|
||||
import MetricCard from './MetricCard';
|
||||
import SystemStatusCard from './SystemStatusCards';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface OverviewCardsProps {
|
||||
metrics: OverviewMetrics | null;
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function OverviewCards({
|
||||
metrics,
|
||||
traffic,
|
||||
loading,
|
||||
refreshKey,
|
||||
}: OverviewCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('monitoring.totalMessages'),
|
||||
value: metrics?.totalMessages || 0,
|
||||
icon: <MessageSquare />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.messages,
|
||||
direction: (metrics.trends.messages >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.modelCallsCount'),
|
||||
value: metrics?.modelCalls || 0,
|
||||
icon: <Sparkles />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.llmCalls,
|
||||
direction: (metrics.trends.llmCalls >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.successRate'),
|
||||
value: metrics ? `${metrics.successRate}%` : '0%',
|
||||
icon: <Check />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.successRate,
|
||||
direction: (metrics.trends.successRate >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.activeSessions'),
|
||||
value: metrics?.activeSessions || 0,
|
||||
icon: <Users />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.sessions,
|
||||
direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metric Cards + System Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<MetricCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
icon={card.icon}
|
||||
trend={card.trend}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
<SystemStatusCard refreshKey={refreshKey} />
|
||||
</div>
|
||||
|
||||
{/* Traffic Chart */}
|
||||
<TrafficChart traffic={traffic} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Box,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Loader2,
|
||||
Server,
|
||||
Info,
|
||||
Container,
|
||||
Clock,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Image,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -38,6 +38,10 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
type StatusState = 'ok' | 'disabled' | 'failed' | null;
|
||||
|
||||
// Neutral tile: the status dots already carry the health colour, so the card's
|
||||
// own tile must not encode a second, conflicting verdict.
|
||||
const SYSTEM_STATUS_ACCENT = '#64748b';
|
||||
|
||||
function StatusDot({ state }: { state: StatusState }) {
|
||||
if (state === null)
|
||||
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
|
||||
@@ -118,28 +122,22 @@ export default function SystemStatusCard({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('monitoring.systemStatus')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricCard
|
||||
label={t('monitoring.systemStatus')}
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
accent={SYSTEM_STATUS_ACCENT}
|
||||
loading
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="transition-all duration-300 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('monitoring.systemStatus')}
|
||||
</CardTitle>
|
||||
<MetricCard
|
||||
label={t('monitoring.systemStatus')}
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
accent={SYSTEM_STATUS_ACCENT}
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -148,8 +146,9 @@ export default function SystemStatusCard({
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot state={pluginState} />
|
||||
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -162,8 +161,8 @@ export default function SystemStatusCard({
|
||||
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface TrafficChartProps {
|
||||
@@ -41,24 +43,24 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
|
||||
<Skeleton className="h-5 w-32 rounded" />
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse w-full h-full bg-muted rounded"></div>
|
||||
<Skeleton className="w-full h-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-base font-semibold text-foreground mb-4">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
@@ -72,12 +74,12 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
|
||||
<Card className="p-6 transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
@@ -173,6 +175,6 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { TimeRangeOption, DateRange } from '../types/monitoring';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { resolveMonitoringWindow } from '../utils/dateUtils';
|
||||
import type {
|
||||
ExecutionListResult,
|
||||
ExecutionModeFilter,
|
||||
ExecutionSourceFilter,
|
||||
} from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
/**
|
||||
* Custom hook for fetching the unified execution list
|
||||
*/
|
||||
export function useExecutions(params: {
|
||||
selectedBots: string[];
|
||||
selectedPipelines: string[];
|
||||
selectedAgents: string[];
|
||||
source: ExecutionSourceFilter;
|
||||
mode: ExecutionModeFilter;
|
||||
statusGroup: string;
|
||||
timeRange: TimeRangeOption;
|
||||
customDateRange: DateRange | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const [result, setResult] = useState<ExecutionListResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
|
||||
const requestIdRef = useRef(0);
|
||||
const scope = JSON.stringify([workspaceUuid, params]);
|
||||
const [requestScope, setRequestScope] = useState<string | null>(null);
|
||||
|
||||
// Memoize filter parameters to prevent unnecessary re-renders
|
||||
const selectedBotsStr = useMemo(
|
||||
() => JSON.stringify(params.selectedBots),
|
||||
[params.selectedBots],
|
||||
);
|
||||
const selectedPipelinesStr = useMemo(
|
||||
() => JSON.stringify(params.selectedPipelines),
|
||||
[params.selectedPipelines],
|
||||
);
|
||||
const selectedAgentsStr = useMemo(
|
||||
() => JSON.stringify(params.selectedAgents),
|
||||
[params.selectedAgents],
|
||||
);
|
||||
const customDateRangeStr = useMemo(
|
||||
() => JSON.stringify(params.customDateRange),
|
||||
[params.customDateRange],
|
||||
);
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { startTime, endTime } = resolveMonitoringWindow(
|
||||
params.timeRange,
|
||||
params.customDateRange,
|
||||
);
|
||||
|
||||
const response = await backendClient.getExecutions({
|
||||
botId: params.selectedBots.length > 0 ? params.selectedBots : undefined,
|
||||
pipelineId:
|
||||
params.selectedPipelines.length > 0
|
||||
? params.selectedPipelines
|
||||
: undefined,
|
||||
agentId:
|
||||
params.selectedAgents.length > 0 ? params.selectedAgents : undefined,
|
||||
status: params.statusGroup === 'all' ? undefined : [params.statusGroup],
|
||||
source: params.source,
|
||||
mode: params.mode,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
|
||||
setResult(response);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch executions:', err);
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [
|
||||
params.selectedBots,
|
||||
params.selectedPipelines,
|
||||
params.selectedAgents,
|
||||
params.source,
|
||||
params.mode,
|
||||
params.statusGroup,
|
||||
params.timeRange,
|
||||
params.customDateRange,
|
||||
params.limit,
|
||||
params.offset,
|
||||
scope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Fetch data when filter state changes
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
selectedAgentsStr,
|
||||
params.source,
|
||||
params.mode,
|
||||
params.statusGroup,
|
||||
params.timeRange,
|
||||
customDateRangeStr,
|
||||
params.limit,
|
||||
params.offset,
|
||||
params.refreshKey,
|
||||
nonce,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Manual refetch function
|
||||
const refetch = () => {
|
||||
setNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
return {
|
||||
result: requestScope === scope ? result : null,
|
||||
loading: requestScope !== scope || loading,
|
||||
error: requestScope === scope ? error : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../types/monitoring';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
import { parseUTCTimestamp, resolveMonitoringWindow } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
* Custom hook for fetching and managing monitoring data
|
||||
@@ -36,45 +36,6 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
[filterState.customDateRange],
|
||||
);
|
||||
|
||||
// Convert time range to datetime strings
|
||||
const getTimeRange = useCallback(() => {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
switch (filterState.timeRange) {
|
||||
case 'lastHour':
|
||||
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last6Hours':
|
||||
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last24Hours':
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (filterState.customDateRange) {
|
||||
startTime = filterState.customDateRange.from;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
filterState.timeRange === 'custom' && filterState.customDateRange
|
||||
? filterState.customDateRange.to
|
||||
: now;
|
||||
|
||||
return {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}, [filterState.timeRange, filterState.customDateRange]);
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
@@ -87,7 +48,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { startTime, endTime } = getTimeRange();
|
||||
const { startTime, endTime } = resolveMonitoringWindow(
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
);
|
||||
|
||||
const response = await backendClient.getMonitoringData({
|
||||
botId:
|
||||
@@ -426,7 +390,8 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [
|
||||
getTimeRange,
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import React, { Suspense, useState, useMemo, useCallback } from 'react';
|
||||
import React, {
|
||||
Suspense,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
@@ -10,8 +19,11 @@ import {
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import OverviewCards from './components/overview-cards/OverviewCards';
|
||||
import { TabState } from './components/TabState';
|
||||
import SystemStatusCard from './components/overview-cards/SystemStatusCards';
|
||||
import TrafficChart from './components/overview-cards/TrafficChart';
|
||||
import MonitoringFilters from './components/filters/MonitoringFilters';
|
||||
import TokenMonitoring from './components/TokenMonitoring';
|
||||
import { ExportDropdown } from './components/ExportDropdown';
|
||||
@@ -21,8 +33,20 @@ import { useFeedbackData } from './hooks/useFeedbackData';
|
||||
import { ConversationTurnList } from './components/ConversationTurnList';
|
||||
import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import ExecutionOverviewCards from './components/overview-cards/ExecutionOverviewCards';
|
||||
import ExecutionTable from './components/executions/ExecutionTable';
|
||||
import ExecutionDetailSheet from './components/executions/ExecutionDetailSheet';
|
||||
import { useExecutions } from './hooks/useExecutions';
|
||||
import type {
|
||||
ExecutionModeFilter,
|
||||
ExecutionRow,
|
||||
ExecutionSourceFilter,
|
||||
} from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { buildConversationTurns } from './utils/conversationTurns';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
import { resolveCallTarget } from './utils/callLinks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveMonitoringWindow } from './utils/dateUtils';
|
||||
import { LoadingPage } from '@/components/ui/loading-spinner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
function MonitoringPageContent() {
|
||||
@@ -34,48 +58,62 @@ function MonitoringPageContent() {
|
||||
useMonitoringFilters();
|
||||
const { data, loading, error, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Unified execution view (agent runs + pipeline queries). Its filters are
|
||||
// independent of the legacy message/model tabs.
|
||||
const [executionSource, setExecutionSource] =
|
||||
useState<ExecutionSourceFilter>('all');
|
||||
const [executionMode, setExecutionMode] =
|
||||
useState<ExecutionModeFilter>('all');
|
||||
const [executionStatus, setExecutionStatus] = useState<string>('all');
|
||||
const [executionOffset, setExecutionOffset] = useState(0);
|
||||
const [selectedExecution, setSelectedExecution] =
|
||||
useState<ExecutionRow | null>(null);
|
||||
const EXECUTIONS_PAGE_SIZE = 50;
|
||||
|
||||
const {
|
||||
result: executionResult,
|
||||
loading: executionLoading,
|
||||
error: executionError,
|
||||
refetch: refetchExecutions,
|
||||
} = useExecutions({
|
||||
selectedBots: filterState.selectedBots,
|
||||
selectedPipelines: filterState.selectedPipelines,
|
||||
selectedAgents: [],
|
||||
source: executionSource,
|
||||
mode: executionMode,
|
||||
statusGroup: executionStatus,
|
||||
timeRange: filterState.timeRange,
|
||||
customDateRange: filterState.customDateRange,
|
||||
limit: EXECUTIONS_PAGE_SIZE,
|
||||
offset: executionOffset,
|
||||
});
|
||||
|
||||
// Reset paging whenever the execution filters narrow the result set.
|
||||
useEffect(() => {
|
||||
setExecutionOffset(0);
|
||||
}, [
|
||||
executionSource,
|
||||
executionMode,
|
||||
executionStatus,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
]);
|
||||
|
||||
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
|
||||
// Get time range for feedback data
|
||||
const feedbackTimeRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
switch (filterState.timeRange) {
|
||||
case 'lastHour':
|
||||
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last6Hours':
|
||||
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last24Hours':
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (filterState.customDateRange) {
|
||||
startTime = filterState.customDateRange.from;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
filterState.timeRange === 'custom' && filterState.customDateRange
|
||||
? filterState.customDateRange.to
|
||||
: now;
|
||||
|
||||
return {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
const feedbackTimeRange = useMemo(
|
||||
() =>
|
||||
resolveMonitoringWindow(
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
|
||||
[filterState.timeRange, filterState.customDateRange, feedbackRefreshKey],
|
||||
);
|
||||
|
||||
// Feedback data hook
|
||||
const {
|
||||
@@ -99,8 +137,9 @@ function MonitoringPageContent() {
|
||||
// Combined refresh handler for both monitoring and feedback data
|
||||
const handleRefresh = useCallback(() => {
|
||||
refetch();
|
||||
refetchExecutions();
|
||||
setFeedbackRefreshKey((k) => k + 1);
|
||||
}, [refetch]);
|
||||
}, [refetch, refetchExecutions]);
|
||||
|
||||
const conversationTurns = useMemo(
|
||||
() =>
|
||||
@@ -118,7 +157,14 @@ function MonitoringPageContent() {
|
||||
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
||||
|
||||
// State for controlled tabs
|
||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||
const [activeTab, setActiveTab] = useState<string>('executions');
|
||||
|
||||
// Ids the message list actually holds: a call may name a record that the
|
||||
// current window does not contain, and a dead link is worse than no link.
|
||||
const messageIds = useMemo(
|
||||
() => new Set(data?.messages.map((message) => message.id) ?? []),
|
||||
[data?.messages],
|
||||
);
|
||||
|
||||
// Function to jump to a message record
|
||||
const jumpToMessage = (messageId: string) => {
|
||||
@@ -148,7 +194,7 @@ function MonitoringPageContent() {
|
||||
{/* Filters and Refresh Button - Sticky */}
|
||||
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 p-3 bg-card rounded-xl border sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-4 sm:p-4">
|
||||
<Card className="flex flex-col gap-3 p-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-4 sm:p-4">
|
||||
<MonitoringFilters
|
||||
selectedBots={filterState.selectedBots}
|
||||
selectedPipelines={filterState.selectedPipelines}
|
||||
@@ -156,6 +202,12 @@ function MonitoringPageContent() {
|
||||
onBotsChange={setSelectedBots}
|
||||
onPipelinesChange={setSelectedPipelines}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
source={executionSource}
|
||||
onSourceChange={setExecutionSource}
|
||||
mode={executionMode}
|
||||
onModeChange={setExecutionMode}
|
||||
statusGroup={executionStatus}
|
||||
onStatusGroupChange={setExecutionStatus}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{canExport && <ExportDropdown filterState={filterState} />}
|
||||
@@ -169,29 +221,36 @@ function MonitoringPageContent() {
|
||||
{t('monitoring.refreshData')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
{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>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('monitoring.loadError')}</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
{/* Overview Section: the execution metrics and the runtime status share
|
||||
one grid, so a card always lands in the same row regardless of how
|
||||
many metrics reported. */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ExecutionOverviewCards
|
||||
summary={executionResult?.summary ?? null}
|
||||
loading={executionLoading}
|
||||
activeSessions={data?.overview?.activeSessions ?? null}
|
||||
/>
|
||||
<SystemStatusCard refreshKey={feedbackRefreshKey} />
|
||||
</div>
|
||||
|
||||
{/* Traffic Chart */}
|
||||
<TrafficChart traffic={data?.traffic} loading={loading} />
|
||||
|
||||
{/* Tabs Section */}
|
||||
{!loading && data && (
|
||||
@@ -235,14 +294,20 @@ function MonitoringPageContent() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
{/* Detail records region: one fixed-height surface for every tab. Tab
|
||||
switches and expanded records scroll inside it, so the page never
|
||||
resizes and nothing below the fold is pushed out of view. */}
|
||||
<div className="bg-card flex h-[min(72vh,48rem)] min-h-[24rem] flex-col overflow-hidden rounded-xl border">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
className="flex h-full min-h-0 w-full flex-col gap-0"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<div className="border-b px-3 pt-4 pb-3 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="executions" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.execution.tabs.executions')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
@@ -261,15 +326,99 @@ function MonitoringPageContent() {
|
||||
</TabsList>
|
||||
</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')}
|
||||
/>
|
||||
<TabsContent
|
||||
value="executions"
|
||||
className="m-0 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<p className="px-3 pt-4 text-sm text-muted-foreground sm:px-6">
|
||||
{t('monitoring.execution.subtitle')}
|
||||
</p>
|
||||
|
||||
{executionError ? (
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-3 pt-4 pb-4 sm:px-6">
|
||||
{executionLoading ? (
|
||||
<TabState loading rows={6} />
|
||||
) : (executionResult?.items.length ?? 0) === 0 ? (
|
||||
<TabState
|
||||
icon={<Activity className="h-12 w-12" />}
|
||||
title={t('monitoring.execution.empty')}
|
||||
/>
|
||||
) : (
|
||||
<ExecutionTable
|
||||
rows={executionResult?.items ?? []}
|
||||
selectedId={selectedExecution?.id}
|
||||
onSelect={setSelectedExecution}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{executionResult &&
|
||||
executionResult.total > EXECUTIONS_PAGE_SIZE && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-3 py-3 sm:px-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={executionOffset === 0}
|
||||
onClick={() =>
|
||||
setExecutionOffset(
|
||||
Math.max(
|
||||
0,
|
||||
executionOffset - EXECUTIONS_PAGE_SIZE,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('common.previous')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{executionOffset + 1}–
|
||||
{Math.min(
|
||||
executionOffset + EXECUTIONS_PAGE_SIZE,
|
||||
executionResult.total,
|
||||
)}{' '}
|
||||
/ {executionResult.total}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!executionResult.has_more}
|
||||
onClick={() =>
|
||||
setExecutionOffset(
|
||||
executionOffset + EXECUTIONS_PAGE_SIZE,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="messages"
|
||||
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
|
||||
>
|
||||
<div>
|
||||
{loading && <TabState loading rows={6} />}
|
||||
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
@@ -280,214 +429,272 @@ function MonitoringPageContent() {
|
||||
)}
|
||||
|
||||
{!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>
|
||||
<TabState
|
||||
icon={<MessageSquare className="h-12 w-12" />}
|
||||
title={t('monitoring.messageList.noMessages')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<TabsContent
|
||||
value="modelCalls"
|
||||
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
|
||||
>
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
{loading && <TabState loading rows={6} />}
|
||||
|
||||
{!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!)
|
||||
{data.modelCalls.map((call) => {
|
||||
// A recorded call names the record it belongs to, so
|
||||
// the whole card opens it; without a match the card
|
||||
// stays static instead of pretending to be a link.
|
||||
const target = resolveCallTarget(
|
||||
call,
|
||||
executionResult?.items ?? [],
|
||||
messageIds,
|
||||
);
|
||||
const openTarget = () => {
|
||||
if (!target) return;
|
||||
if (target.kind === 'execution') {
|
||||
setActiveTab('executions');
|
||||
setSelectedExecution(target.row);
|
||||
return;
|
||||
}
|
||||
jumpToMessage(target.id);
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
key={call.id}
|
||||
role={target ? 'button' : undefined}
|
||||
tabIndex={target ? 0 : undefined}
|
||||
aria-label={
|
||||
target
|
||||
? t('monitoring.execution.detail.openObject')
|
||||
: undefined
|
||||
}
|
||||
onClick={target ? openTarget : undefined}
|
||||
onKeyDown={
|
||||
target
|
||||
? (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (
|
||||
event.key === 'Enter' ||
|
||||
event.key === ' '
|
||||
) {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'gap-0 px-4 py-4 transition-all duration-200 sm:px-5 sm:py-5',
|
||||
target &&
|
||||
'cursor-pointer hover:bg-muted/40 focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none',
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* The stored id is the owning record: a
|
||||
Pipeline's message, or an Agent run's id
|
||||
that the card itself opens. */}
|
||||
{call.messageId &&
|
||||
messageIds.has(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={(event) => {
|
||||
// The card itself opens the call's
|
||||
// record; this button is the more
|
||||
// specific message target.
|
||||
event.stopPropagation();
|
||||
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 */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
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'
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingModel',
|
||||
)}
|
||||
</Badge>
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
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',
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
{/* Status Badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
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}
|
||||
</Badge>
|
||||
</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>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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 && (
|
||||
{/* 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.llmCalls.inputTokens',
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.tokens.input}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.outputTokens',
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.tokens.output}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.totalTokens',
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.tokens.total}
|
||||
: {call.inputCount}
|
||||
</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)}
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</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">
|
||||
{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.queryText',
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
:{' '}
|
||||
</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}
|
||||
: {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="ml-4 flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{call.timestamp.toLocaleString()}
|
||||
{target && (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -495,17 +702,18 @@ function MonitoringPageContent() {
|
||||
(!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>
|
||||
<TabState
|
||||
icon={<Sparkles className="h-12 w-12" />}
|
||||
title={t('monitoring.modelCalls.noData')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TabsContent
|
||||
value="tokens"
|
||||
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
|
||||
>
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
@@ -523,13 +731,12 @@ function MonitoringPageContent() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<TabsContent
|
||||
value="feedback"
|
||||
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
|
||||
>
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
{loading && <TabState loading rows={6} />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
@@ -542,7 +749,7 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
@@ -555,13 +762,12 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<TabsContent
|
||||
value="errors"
|
||||
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
|
||||
>
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
{loading && <TabState loading rows={6} />}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
@@ -569,9 +775,9 @@ function MonitoringPageContent() {
|
||||
data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
<Card
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
className="gap-0 overflow-hidden border-red-200 py-0 transition-all duration-200 dark:border-red-900"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
@@ -704,19 +910,19 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</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')}
|
||||
</div>
|
||||
</div>
|
||||
<TabState
|
||||
icon={
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500 dark:text-green-600" />
|
||||
}
|
||||
title={t('monitoring.errors.noErrors')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -724,6 +930,12 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution trace drawer */}
|
||||
<ExecutionDetailSheet
|
||||
row={selectedExecution}
|
||||
onClose={() => setSelectedExecution(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
/**
|
||||
* The identity a recorded call carries: every call kind (model, tool) has a time,
|
||||
* a session and - when a Pipeline recorded it - the message it belongs to.
|
||||
*/
|
||||
export interface CallLinkSource {
|
||||
timestamp: Date;
|
||||
sessionId?: string | null;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
/** A record the dashboard can open for a recorded model or tool call. */
|
||||
export type CallTarget =
|
||||
| { kind: 'execution'; row: ExecutionRow }
|
||||
| { kind: 'message'; id: string };
|
||||
|
||||
/** Calls are matched against a run's window; clock skew must not lose the link. */
|
||||
const WINDOW_SLACK_MS = 2000;
|
||||
|
||||
function containsCall(row: ExecutionRow, callTime: number): boolean {
|
||||
if (row.started_at_ms == null) {
|
||||
return false;
|
||||
}
|
||||
const started = row.started_at_ms;
|
||||
if (callTime < started - WINDOW_SLACK_MS) {
|
||||
return false;
|
||||
}
|
||||
if (row.finished_at_ms == null) {
|
||||
// A run with no end time is still open: anything after its start belongs to it.
|
||||
return true;
|
||||
}
|
||||
return callTime <= row.finished_at_ms + WINDOW_SLACK_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The execution a recorded call ran inside.
|
||||
*
|
||||
* A Pipeline call names its message, which is the same id the execution list
|
||||
* uses for that query. An Agent call names nothing, so it is matched to the run
|
||||
* sharing its session whose window contains the call; the latest such start
|
||||
* wins, so a session with consecutive runs links to the run that was live.
|
||||
*/
|
||||
export function findCallExecution(
|
||||
call: CallLinkSource,
|
||||
executions: ExecutionRow[],
|
||||
): ExecutionRow | null {
|
||||
if (executions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageId = call.messageId;
|
||||
if (messageId) {
|
||||
const direct = executions.find((row) => row.id === messageId);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = call.sessionId;
|
||||
const callTime = call.timestamp.getTime();
|
||||
let matched: ExecutionRow | null = null;
|
||||
for (const row of executions) {
|
||||
if (
|
||||
!sessionId ||
|
||||
row.session_id !== sessionId ||
|
||||
!containsCall(row, callTime)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
matched == null ||
|
||||
(row.started_at_ms ?? 0) > (matched.started_at_ms ?? 0)
|
||||
) {
|
||||
matched = row;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a call's card jumps to: its execution record when the loaded list knows
|
||||
* it, else the pipeline message it names. An Agent call carries its run id, so
|
||||
* the message fallback applies only to ids the message list actually holds.
|
||||
*/
|
||||
export function resolveCallTarget(
|
||||
call: CallLinkSource,
|
||||
executions: ExecutionRow[],
|
||||
messageIds?: ReadonlySet<string>,
|
||||
): CallTarget | null {
|
||||
const row = findCallExecution(call, executions);
|
||||
if (row) {
|
||||
return { kind: 'execution', row };
|
||||
}
|
||||
const messageId = call.messageId;
|
||||
if (messageId && messageIds?.has(messageId)) {
|
||||
return { kind: 'message', id: messageId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -116,3 +116,48 @@ export function parseUTCTimestamp(timestamp: string): Date {
|
||||
}
|
||||
return new Date(timestamp + 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a monitoring time window to ISO start/end strings.
|
||||
*
|
||||
* Single source of the window semantics for the dashboard, the unified
|
||||
* execution list, and the feedback queries.
|
||||
*/
|
||||
export function resolveMonitoringWindow(
|
||||
timeRange: TimeRangeOption,
|
||||
customDateRange: DateRange | null,
|
||||
): { startTime: string; endTime: string } {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
switch (timeRange) {
|
||||
case 'lastHour':
|
||||
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last6Hours':
|
||||
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last24Hours':
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (customDateRange) {
|
||||
startTime = customDateRange.from;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
timeRange === 'custom' && customDateRange ? customDateRange.to : now;
|
||||
|
||||
return {
|
||||
startTime: (startTime ?? now).toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -950,3 +950,20 @@ export interface ApiRespSkills {
|
||||
export interface ApiRespSkill {
|
||||
skill: Skill;
|
||||
}
|
||||
|
||||
export type {
|
||||
ExecutionSource,
|
||||
ExecutionSourceFilter,
|
||||
ExecutionModeFilter,
|
||||
ExecutionUsage,
|
||||
ExecutionRow,
|
||||
ExecutionOverview,
|
||||
TokenCoverage,
|
||||
ExecutionSummary,
|
||||
ExecutionListResult,
|
||||
PipelineMessageRow,
|
||||
ExecutionCallRow,
|
||||
AgentExecutionDetail,
|
||||
PipelineExecutionDetail,
|
||||
ExecutionDetail,
|
||||
} from './monitoring-executions';
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ProcessorRun, ProcessorRunEvent } from './index';
|
||||
|
||||
export type ExecutionSource = 'agent' | 'pipeline';
|
||||
export type ExecutionSourceFilter = 'all' | ExecutionSource;
|
||||
export type ExecutionModeFilter = 'all' | 'real' | 'debug';
|
||||
|
||||
export interface ExecutionUsage {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
total_tokens?: number | null;
|
||||
}
|
||||
|
||||
export interface ExecutionRow {
|
||||
source: ExecutionSource;
|
||||
id: string;
|
||||
event_id: string | null;
|
||||
status: string;
|
||||
status_group:
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'running'
|
||||
| 'queued'
|
||||
| 'cancelled'
|
||||
| 'ignored'
|
||||
| string;
|
||||
title: string;
|
||||
target_kind: 'agent' | 'pipeline' | 'processor' | string;
|
||||
target_id: string | null;
|
||||
target_name: string | null;
|
||||
bot_id: string | null;
|
||||
bot_name: string | null;
|
||||
pipeline_id: string | null;
|
||||
pipeline_name: string | null;
|
||||
runner_id: string | null;
|
||||
conversation_id: string | null;
|
||||
session_id: string | null;
|
||||
created_at_ms: number | null;
|
||||
started_at_ms: number | null;
|
||||
finished_at_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
usage: ExecutionUsage | null;
|
||||
cost: Record<string, unknown> | null;
|
||||
queue_name: string | null;
|
||||
debug: boolean;
|
||||
has_error: boolean;
|
||||
platform?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionOverview {
|
||||
total: number;
|
||||
by_source: { agent: number; pipeline: number };
|
||||
by_status: Record<string, number>;
|
||||
completed: number;
|
||||
failed: number;
|
||||
running: number;
|
||||
queued: number;
|
||||
cancelled: number;
|
||||
ignored: number;
|
||||
waiting: number;
|
||||
success_rate: number | null;
|
||||
denominator: number;
|
||||
p50_duration_ms: number | null;
|
||||
p95_duration_ms: number | null;
|
||||
duration_sample: number;
|
||||
debug: number;
|
||||
real: number;
|
||||
}
|
||||
|
||||
export interface TokenCoverage {
|
||||
total_tokens: number;
|
||||
calls: number;
|
||||
calls_with_usage: number;
|
||||
}
|
||||
|
||||
export interface ExecutionSummary {
|
||||
executions: ExecutionOverview;
|
||||
tokens: TokenCoverage;
|
||||
}
|
||||
|
||||
export interface ExecutionListResult {
|
||||
items: ExecutionRow[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
summary: ExecutionSummary;
|
||||
}
|
||||
|
||||
export interface PipelineMessageRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
message_content: string;
|
||||
session_id: string;
|
||||
status: string;
|
||||
level: string;
|
||||
platform?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
runner_name?: string | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionCallRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
/** LLM/tool calls report a status; error records carry `error_type` instead. */
|
||||
status?: string | null;
|
||||
duration?: number | null;
|
||||
error_type?: string | null;
|
||||
error_message?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentExecutionDetail {
|
||||
source: 'agent';
|
||||
run: ProcessorRun;
|
||||
events: ProcessorRunEvent[];
|
||||
has_more: boolean;
|
||||
next_cursor: number | null;
|
||||
}
|
||||
|
||||
export interface PipelineExecutionDetail {
|
||||
source: 'pipeline';
|
||||
message: PipelineMessageRow;
|
||||
llm_calls: ExecutionCallRow[];
|
||||
tool_calls: ExecutionCallRow[];
|
||||
errors: ExecutionCallRow[];
|
||||
}
|
||||
|
||||
export type ExecutionDetail = AgentExecutionDetail | PipelineExecutionDetail;
|
||||
@@ -4,6 +4,13 @@ import type {
|
||||
PipelineMigrationRequest,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
import type {
|
||||
ExecutionDetail,
|
||||
ExecutionListResult,
|
||||
ExecutionModeFilter,
|
||||
ExecutionSource,
|
||||
ExecutionSourceFilter,
|
||||
} from '../entities/api/monitoring-executions';
|
||||
import type {
|
||||
CodexAuthStatus,
|
||||
CodexDeviceAuthorization,
|
||||
@@ -2115,6 +2122,64 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getExecutions(params: {
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
agentId?: string[];
|
||||
status?: string[];
|
||||
source?: ExecutionSourceFilter;
|
||||
mode?: ExecutionModeFilter;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ExecutionListResult> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botId) {
|
||||
params.botId.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineId) {
|
||||
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
|
||||
}
|
||||
if (params.agentId) {
|
||||
params.agentId.forEach((id) => queryParams.append('agentId', id));
|
||||
}
|
||||
if (params.status) {
|
||||
params.status.forEach((status) => queryParams.append('status', status));
|
||||
}
|
||||
if (params.source) {
|
||||
queryParams.append('source', params.source);
|
||||
}
|
||||
if (params.mode) {
|
||||
queryParams.append('mode', params.mode);
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.append('limit', params.limit.toString());
|
||||
}
|
||||
if (params.offset) {
|
||||
queryParams.append('offset', params.offset.toString());
|
||||
}
|
||||
|
||||
return this.get(
|
||||
`/api/v1/monitoring/executions?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getExecutionDetail(
|
||||
source: ExecutionSource,
|
||||
executionId: string,
|
||||
): Promise<ExecutionDetail> {
|
||||
return this.get(
|
||||
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getMonitoringOverview(params: {
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
|
||||
@@ -2,11 +2,18 @@ import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
// `containerClassName` styles the scroll container shadcn wraps every table in.
|
||||
// A caller that owns the scrolling region (a fixed-height panel) uses it to make
|
||||
// that container the scroller, which is what keeps a sticky header working.
|
||||
function Table({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<'table'> & { containerClassName?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
className={cn('relative w-full overflow-x-auto', containerClassName)}
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
|
||||
@@ -2456,6 +2456,83 @@ const enUS = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
execution: {
|
||||
title: 'Executions',
|
||||
subtitle:
|
||||
'Every inbound event and the execution it triggered, across agents and pipelines.',
|
||||
tabs: { executions: 'Executions' },
|
||||
filters: {
|
||||
source: 'Type',
|
||||
sourceAll: 'All types',
|
||||
sourceAgent: 'Agents',
|
||||
sourcePipeline: 'Pipelines',
|
||||
mode: 'Traffic',
|
||||
modeAll: 'Live + debug',
|
||||
modeLive: 'Live only',
|
||||
modeDebug: 'Debug only',
|
||||
status: 'Status',
|
||||
statusAll: 'All statuses',
|
||||
},
|
||||
status: {
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
running: 'Running',
|
||||
queued: 'Queued',
|
||||
cancelled: 'Cancelled',
|
||||
ignored: 'Ignored',
|
||||
},
|
||||
kind: { agent: 'Agent', pipeline: 'Pipeline', processor: 'Processor' },
|
||||
columns: {
|
||||
time: 'Time',
|
||||
target: 'Processor',
|
||||
status: 'Status',
|
||||
duration: 'Duration',
|
||||
tokens: 'Tokens',
|
||||
},
|
||||
debug: 'Debug',
|
||||
empty: 'No executions in this range',
|
||||
loading: 'Loading executions...',
|
||||
triggeredBy: 'Triggered by {{trigger}}',
|
||||
cards: {
|
||||
totalExecutions: 'Executions',
|
||||
bySource: '{{agent}} agent · {{pipeline}} pipeline',
|
||||
successRate: 'Execution Success Rate',
|
||||
successRateHint:
|
||||
'Completed ÷ (completed + failed). Ignored events and in-flight runs are excluded from the denominator.',
|
||||
denominator: '{{completed}} of {{denominator}} settled',
|
||||
running: 'Running',
|
||||
queued: 'Queued',
|
||||
waiting: 'Waiting input',
|
||||
failed: 'Failed',
|
||||
ignored: 'Ignored',
|
||||
latency: 'Duration P50 / P95',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: 'sample of {{count}} runs',
|
||||
tokens: 'Tokens',
|
||||
coverage: 'usage on {{withUsage}}/{{calls}} calls',
|
||||
hiddenDebug: '{{count}} debug runs hidden',
|
||||
},
|
||||
detail: {
|
||||
title: 'Execution',
|
||||
timeline: 'Timeline',
|
||||
executionId: 'Execution ID',
|
||||
eventId: 'Event',
|
||||
queue: 'Queue',
|
||||
runner: 'Runner',
|
||||
created: 'Created',
|
||||
duration: 'Duration',
|
||||
usage: 'Usage',
|
||||
noEvents: 'No trace events recorded',
|
||||
query: 'Inbound message',
|
||||
modelCalls: 'Model calls',
|
||||
toolCalls: 'Tool calls',
|
||||
errors: 'Errors',
|
||||
loadError: 'Failed to load execution trace',
|
||||
openObject: 'Open object',
|
||||
a11yDescription: 'Full trace of this execution',
|
||||
},
|
||||
},
|
||||
title: 'Dashboard',
|
||||
description: 'Monitor bot activities, LLM calls, and system performance',
|
||||
overview: 'Overview',
|
||||
|
||||
@@ -2308,6 +2308,82 @@ const zhHans = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
execution: {
|
||||
title: '执行记录',
|
||||
subtitle: '每条入站事件及其触发的执行,覆盖 Agent 与 Pipeline。',
|
||||
tabs: { executions: '执行记录' },
|
||||
filters: {
|
||||
source: '类型',
|
||||
sourceAll: '全部类型',
|
||||
sourceAgent: 'Agent',
|
||||
sourcePipeline: 'Pipeline',
|
||||
mode: '流量',
|
||||
modeAll: '真实 + 调试',
|
||||
modeLive: '仅真实',
|
||||
modeDebug: '仅调试',
|
||||
status: '状态',
|
||||
statusAll: '全部状态',
|
||||
},
|
||||
status: {
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
running: '运行中',
|
||||
queued: '排队中',
|
||||
cancelled: '已取消',
|
||||
ignored: '已忽略',
|
||||
},
|
||||
kind: { agent: 'Agent', pipeline: 'Pipeline', processor: '处理器' },
|
||||
columns: {
|
||||
time: '时间',
|
||||
target: '处理器',
|
||||
status: '状态',
|
||||
duration: '耗时',
|
||||
tokens: 'Token',
|
||||
},
|
||||
debug: '调试',
|
||||
empty: '该时间范围内暂无执行记录',
|
||||
loading: '加载执行记录中...',
|
||||
triggeredBy: '触发:{{trigger}}',
|
||||
cards: {
|
||||
totalExecutions: '执行总数',
|
||||
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
|
||||
successRate: '执行成功率',
|
||||
successRateHint:
|
||||
'已完成 ÷(已完成 + 失败)。已忽略事件与执行中的任务不计入分母。',
|
||||
denominator: '{{completed}} / {{denominator}} 已结束',
|
||||
running: '运行中',
|
||||
queued: '排队中',
|
||||
waiting: '等待交互',
|
||||
failed: '失败',
|
||||
ignored: '已忽略',
|
||||
latency: '耗时 P50 / P95',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: '样本 {{count}} 次',
|
||||
tokens: 'Token',
|
||||
coverage: '{{withUsage}}/{{calls}} 次调用有用量',
|
||||
hiddenDebug: '已隐藏 {{count}} 条调试运行',
|
||||
},
|
||||
detail: {
|
||||
title: '执行详情',
|
||||
timeline: '执行时间线',
|
||||
executionId: '执行 ID',
|
||||
eventId: '事件',
|
||||
queue: '队列',
|
||||
runner: 'Runner',
|
||||
created: '创建时间',
|
||||
duration: '耗时',
|
||||
usage: '用量',
|
||||
noEvents: '暂无追踪事件',
|
||||
query: '入站消息',
|
||||
modelCalls: '模型调用',
|
||||
toolCalls: '工具调用',
|
||||
errors: '错误',
|
||||
loadError: '加载执行轨迹失败',
|
||||
openObject: '打开对象',
|
||||
a11yDescription: '此次执行的完整追踪',
|
||||
},
|
||||
},
|
||||
title: '仪表盘',
|
||||
description: '查看机器人活动、LLM调用和系统性能',
|
||||
overview: '概览',
|
||||
|
||||
@@ -2309,6 +2309,82 @@ const zhHant = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
execution: {
|
||||
title: '執行記錄',
|
||||
subtitle: '每條入站事件及其觸發的執行,涵蓋 Agent 與 Pipeline。',
|
||||
tabs: { executions: '執行記錄' },
|
||||
filters: {
|
||||
source: '類型',
|
||||
sourceAll: '全部類型',
|
||||
sourceAgent: 'Agent',
|
||||
sourcePipeline: 'Pipeline',
|
||||
mode: '流量',
|
||||
modeAll: '真實 + 偵錯',
|
||||
modeLive: '僅真實',
|
||||
modeDebug: '僅偵錯',
|
||||
status: '狀態',
|
||||
statusAll: '全部狀態',
|
||||
},
|
||||
status: {
|
||||
completed: '已完成',
|
||||
failed: '失敗',
|
||||
running: '執行中',
|
||||
queued: '排隊中',
|
||||
cancelled: '已取消',
|
||||
ignored: '已忽略',
|
||||
},
|
||||
kind: { agent: 'Agent', pipeline: 'Pipeline', processor: '處理器' },
|
||||
columns: {
|
||||
time: '時間',
|
||||
target: '處理器',
|
||||
status: '狀態',
|
||||
duration: '耗時',
|
||||
tokens: 'Token',
|
||||
},
|
||||
debug: '偵錯',
|
||||
empty: '此時間範圍內暫無執行記錄',
|
||||
triggeredBy: '觸發:{{trigger}}',
|
||||
loading: '載入執行記錄中...',
|
||||
cards: {
|
||||
totalExecutions: '執行總數',
|
||||
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
|
||||
successRate: '執行成功率',
|
||||
successRateHint:
|
||||
'已完成 ÷(已完成 + 失敗)。已忽略事件與執行中的任務不計入分母。',
|
||||
denominator: '{{completed}} / {{denominator}} 已結束',
|
||||
running: '執行中',
|
||||
queued: '排隊中',
|
||||
waiting: '等待互動',
|
||||
failed: '失敗',
|
||||
ignored: '已忽略',
|
||||
latency: '耗時 P50 / P95',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: '樣本 {{count}} 次',
|
||||
tokens: 'Token',
|
||||
coverage: '{{withUsage}}/{{calls}} 次呼叫有用量',
|
||||
hiddenDebug: '已隱藏 {{count}} 筆偵錯執行',
|
||||
},
|
||||
detail: {
|
||||
title: '執行詳情',
|
||||
timeline: '執行時間軸',
|
||||
executionId: '執行 ID',
|
||||
eventId: '事件',
|
||||
queue: '佇列',
|
||||
runner: 'Runner',
|
||||
created: '建立時間',
|
||||
duration: '耗時',
|
||||
usage: '用量',
|
||||
noEvents: '暫無追蹤事件',
|
||||
query: '入站訊息',
|
||||
modelCalls: '模型呼叫',
|
||||
toolCalls: '工具呼叫',
|
||||
errors: '錯誤',
|
||||
loadError: '載入執行軌跡失敗',
|
||||
openObject: '開啟物件',
|
||||
a11yDescription: '此次執行的完整追蹤',
|
||||
},
|
||||
},
|
||||
title: '儀表盤',
|
||||
description: '監控機器人活動、LLM調用和系統效能',
|
||||
overview: '概覽',
|
||||
|
||||
Reference in New Issue
Block a user