feat(monitoring): unify execution views and trace tool calls

This commit is contained in:
Hyu
2026-10-07 22:36:10 +08:00
parent de886edc1b
commit 9f76037e64
42 changed files with 4181 additions and 889 deletions
@@ -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,
+482 -270
View File
@@ -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(),
};
}
+17
View File
@@ -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;
+65
View File
@@ -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[];
+9 -2
View File
@@ -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"
+77
View File
@@ -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',
+76
View File
@@ -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: '概览',
+76
View File
@@ -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: '概覽',