fix(monitoring): unify execution records and stabilize streaming cleanup

This commit is contained in:
Hyu
2026-10-08 03:02:37 +08:00
parent 5f0ef7e48e
commit 40a3a941a3
50 changed files with 3742 additions and 976 deletions
@@ -77,6 +77,8 @@ interface TokenStatistics {
}
interface TokenMonitoringProps {
mode?: 'all' | 'real' | 'debug';
statusGroup?: string;
botIds?: string[];
pipelineIds?: string[];
startTime?: string;
@@ -103,6 +105,8 @@ const TOOLTIP_STYLE: React.CSSProperties = {
};
export default function TokenMonitoring({
mode,
statusGroup,
botIds,
pipelineIds,
startTime,
@@ -123,6 +127,9 @@ export default function TokenMonitoring({
setError(null);
try {
const result = await httpClient.getTokenStatistics({
mode,
status:
statusGroup && statusGroup !== 'all' ? [statusGroup] : undefined,
botId: botIds,
pipelineId: pipelineIds,
startTime,
@@ -136,7 +143,16 @@ export default function TokenMonitoring({
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [botIdsKey, pipelineIdsKey, startTime, endTime, bucket, refreshKey]);
}, [
mode,
statusGroup,
botIdsKey,
pipelineIdsKey,
startTime,
endTime,
bucket,
refreshKey,
]);
useEffect(() => {
fetchStats();
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ExternalLink } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import {
Sheet,
SheetContent,
@@ -13,14 +13,14 @@ 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 { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import type {
ExecutionCallRow,
ExecutionDetail,
ExecutionDetailItem,
ExecutionRow,
ExecutionSection,
} from '@/app/infra/entities/api/monitoring-executions';
import AgentExecutionTrace from '@/app/home/agents/components/AgentExecutionTrace';
import { ExecutionValue as Value } from './ExecutionValue';
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
import { formatDateTime } from '../../utils/dateUtils';
import {
@@ -28,329 +28,409 @@ import {
executionProcessorLabel,
executionStatusLabel,
executionStatusVariant,
executionTargetLabel,
} from './ExecutionTable';
interface ExecutionDetailSheetProps {
row: ExecutionRow | null;
onClose: () => void;
}
export type ExecutionSelection = Pick<ExecutionRow, 'id'> & {
source: ExecutionRow['source'] | 'auto';
};
/** 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}`;
}
const mainSections: ExecutionSection[] = ['inputs', 'outputs', 'deliveries'];
const secondarySections: ExecutionSection[] = [
'related',
'conversation',
'llm_calls',
'tool_calls',
'errors',
'events',
];
export default function ExecutionDetailSheet({
row,
onClose,
}: ExecutionDetailSheetProps) {
onSelect,
}: {
row: ExecutionSelection | null;
onClose: () => void;
onSelect: (row: ExecutionSelection) => void;
}) {
const { t } = useTranslation();
const workspace = useCurrentWorkspace()?.workspace.uuid;
const [detail, setDetail] = useState<ExecutionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const requestRef = useRef(0);
const [sectionError, setSectionError] = useState<ExecutionSection | null>(
null,
);
const [busy, setBusy] = useState<ExecutionSection | null>(null);
const [refresh, setRefresh] = useState(0);
const request = useRef(0);
const identity = `${workspace}:${row?.source}:${row?.id}`;
const [loadedIdentity, setLoadedIdentity] = useState('');
const visible = loadedIdentity === identity ? detail : null;
const id = row?.id;
const source = row?.source;
useEffect(() => {
if (!row) {
setDetail(null);
setError(false);
const version = ++request.current;
setDetail(null);
setError(false);
setSectionError(null);
setBusy(null);
if (!id || !source) {
setLoading(false);
return;
}
const requestId = requestRef.current + 1;
requestRef.current = requestId;
setLoading(true);
setError(false);
setDetail(null);
backendClient
.getExecutionDetail(row.source, row.id)
.getExecutionDetail(source, id)
.then((result) => {
if (requestRef.current !== requestId) return;
if (version !== request.current) return;
setDetail(result);
setLoadedIdentity(identity);
})
.catch(() => {
if (requestRef.current !== requestId) return;
setError(true);
if (version === request.current) 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 (version === request.current) setLoading(false);
});
return () => {
request.current += 1;
};
}, [id, source, workspace, identity, refresh]);
const status = visible?.row.status_group;
useEffect(() => {
if (!['running', 'queued'].includes(status ?? '')) return;
// Do not discard paged history with a background refresh.
if (
Object.values(visible?.pages ?? {}).some((page) => page.next_offset > 100)
)
return;
const timer = setInterval(() => setRefresh((value) => value + 1), 5000);
return () => clearInterval(timer);
}, [status, visible]);
const loadMore = async (section: ExecutionSection) => {
if (!visible || !row || busy) return;
const version = request.current;
setBusy(section);
setSectionError(null);
try {
const result = await backendClient.getExecutionDetail(
row.source,
row.id,
{ section, offset: visible.pages[section]?.next_offset ?? 0 },
);
if (version !== request.current) return;
const next = result.pages[section];
if (next)
setDetail(
(previous) =>
previous && {
...previous,
pages: {
...previous.pages,
[section]: {
...next,
items: [
...(previous.pages[section]?.items ?? []),
...next.items,
],
},
},
},
);
} catch {
if (version === request.current) setSectionError(section);
} finally {
if (version === request.current) setBusy(null);
}
if (row.runner_id) {
metadata.push({
label: t('monitoring.execution.detail.runner'),
value: row.runner_id,
});
};
const renderItem = (section: ExecutionSection, item: ExecutionDetailItem) => {
if (section === 'related') {
const related = item as unknown as ExecutionRow;
return (
<Button
variant="outline"
className="h-auto w-full justify-between whitespace-normal py-3 text-left"
onClick={() => onSelect(related)}
>
<span>
{executionProcessorLabel(related)} ·{' '}
{executionKindLabel(related, t)}
</span>
<Badge variant={executionStatusVariant(related.status_group)}>
{executionStatusLabel(related, t)}
</Badge>
</Button>
);
}
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)}`
const content = [
'inputs',
'outputs',
'deliveries',
'conversation',
].includes(section);
const title = content
? [
item.actor_name || item.actor_id,
item.event_type,
item.role &&
t(`monitoring.unified.roles.${item.role}`, {
defaultValue: item.role,
}),
]
.filter(Boolean)
.join(' · ')
: String(
item.model_name ||
item.tool_name ||
item.error_type ||
item.type ||
item.id,
);
return (
<div className="min-w-0 space-y-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="break-all">{title}</span>
<span>
{item.timestamp_ms
? formatDateTime(new Date(item.timestamp_ms))
: typeof item.timestamp === 'string'
? item.timestamp
: ''}
</span>
{item.status && (
<Badge
variant={executionStatusVariant(
['error', 'failed'].includes(item.status)
? 'failed'
: item.status,
)}
>
{item.status}
</Badge>
)}
</div>
{content ? (
<>
<Value value={item.content} />
<Value value={item.attachments} />
{item.origin === 'generated' && section === 'conversation' && (
<Badge variant="outline">{t('monitoring.unified.outputs')}</Badge>
)}
{item.delivery?.delivery_error ? (
<p className="text-sm text-destructive">
{String(item.delivery.delivery_error)}
</p>
) : null}
{Array.isArray(item.metadata?.routes) && (
<Value value={item.metadata.routes} />
)}
</>
) : (
<>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{typeof item.duration === 'number' && (
<span>{formatRunDuration(item.duration)}</span>
)}
{typeof item.total_tokens === 'number' && (
<span>{item.total_tokens.toLocaleString()} Token</span>
)}
</div>
<Value value={item.error_message} />
<details>
<summary className="cursor-pointer text-xs text-muted-foreground">
{t('monitoring.unified.payload')}
</summary>
<div className="mt-2">
<Value value={section === 'events' ? item.data : item} />
</div>
</details>
</>
)}
</div>
);
};
const renderSection = (section: ExecutionSection, primary = false) => {
const page = visible?.pages[section];
if (
!page ||
(!primary && page.items.length === 0 && section !== 'conversation')
)
return null;
const body = (
<div className="mt-3 space-y-3">
{section === 'conversation' && (
<p className="text-xs text-muted-foreground">
{t('monitoring.unified.contextHint')}
</p>
)}
{page.items.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('monitoring.unified.noRecords')}
</p>
)}
{page.items.map((item, i) => (
<div key={`${item.id}-${i}`}>{renderItem(section, item)}</div>
))}
{sectionError === section && (
<p role="alert" className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{page.has_more && (
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() => loadMore(section)}
>
{t('monitoring.unified.loadMore')}
</Button>
)}
</div>
);
const title = `${t(`monitoring.unified.${section}`)} (${page.items.length}${page.has_more ? '+' : ''})`;
return primary ? (
<section key={section}>
<h3 className="text-sm font-semibold">{title}</h3>
{body}
</section>
) : (
<details key={section} className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{title}
</summary>
{body}
</details>
);
};
const record = visible?.row;
const href = record?.target_id
? record.target_kind === 'pipeline'
? `/home/pipelines?id=${encodeURIComponent(record.target_id)}`
: ['agent', 'event_processor', 'processor'].includes(record.target_kind)
? `/home/agents?id=${encodeURIComponent(record.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}
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>
<SheetContent side="right" className="w-full p-0 sm:max-w-3xl">
<SheetHeader className="sr-only">
<SheetTitle>{t('monitoring.execution.detail.title')}</SheetTitle>
<SheetDescription>
{t('monitoring.execution.detail.a11yDescription')}
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-full">
<div className="space-y-6 p-6">
{row && (
<div className="space-y-5 p-5 sm:p-6">
<div className="flex items-start justify-between gap-3 pr-8">
<h2 className="text-lg font-semibold">
{record
? record.source === 'event'
? t('monitoring.unified.unhandled')
: executionProcessorLabel(record)
: t('monitoring.execution.detail.title')}
</h2>
<Button
variant="ghost"
size="icon"
disabled={loading}
aria-label={t('monitoring.refreshData')}
onClick={() => setRefresh((value) => value + 1)}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{loading && <Skeleton className="h-36 w-full" />}
{error && (
<p role="alert" className="text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{record && (
<>
<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)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={executionStatusVariant(record.status_group)}>
{executionStatusLabel(record, t)}
</Badge>
<Badge variant="outline">
{executionKindLabel(record, t)}
</Badge>
{record.debug && (
<Badge variant="secondary">
{t('monitoring.execution.debug')}
</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>
))}
)}
{href && (
<Button asChild variant="outline" size="sm">
<Link to={href}>
{t('monitoring.execution.detail.openObject')}
</Link>
</Button>
)}
</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>
<p className="text-sm text-muted-foreground">
{[
record.bot_name,
record.platform,
record.user_name || record.user_id,
]
.filter(Boolean)
.join(' · ')}
</p>
{record.status_reason && (
<p className="rounded-md bg-muted p-3 text-sm">
{record.status_reason}
</p>
)}
{mainSections.map((section) => renderSection(section, true))}
{secondarySections.map((section) => renderSection(section))}
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm">
{t('monitoring.unified.metadata')}
</summary>
<dl className="mt-3 space-y-2 text-xs">
{[
[t('monitoring.execution.detail.executionId'), record.id],
[
t('monitoring.execution.detail.eventId'),
record.event_id,
],
[
t('monitoring.unified.conversation'),
record.conversation_id || record.session_id,
],
[
t('monitoring.execution.detail.runner'),
record.runner_id,
],
[
t('monitoring.execution.detail.created'),
record.created_at_ms
? formatDateTime(new Date(record.created_at_ms))
: null,
],
[
t('monitoring.execution.detail.duration'),
record.duration_ms != null
? formatRunDuration(record.duration_ms)
: null,
],
]
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="break-all">{value}</dd>
</div>
))}
</dl>
</details>
</>
)}
</div>
@@ -45,17 +45,26 @@ export function executionStatusVariant(
return 'outline';
}
const KIND_GROUPS = ['agent', 'pipeline', 'processor'];
const KIND_GROUPS = [
'agent',
'pipeline',
'processor',
'event_processor',
'event',
];
export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
if (KIND_GROUPS.includes(row.target_kind)) {
if (row.target_kind === 'event_processor')
return t('monitoring.execution.kind.processor');
if (row.target_kind === 'event') return t('monitoring.unified.unhandled');
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') {
if (row.source !== 'pipeline') {
return eventPatternLabel(row.title, t);
}
const title = row.title ?? '';
@@ -85,6 +94,7 @@ export default function ExecutionTable({
<TableRow>
<TableHead>{t('monitoring.execution.columns.time')}</TableHead>
<TableHead>{t('monitoring.execution.columns.target')}</TableHead>
<TableHead>{t('monitoring.unified.inputs')}</TableHead>
<TableHead>{t('monitoring.execution.columns.status')}</TableHead>
<TableHead className="text-right">
{t('monitoring.execution.columns.duration')}
@@ -127,7 +137,9 @@ export default function ExecutionTable({
{executionKindLabel(row, t)}
</Badge>
<span className="truncate font-medium">
{executionProcessorLabel(row)}
{row.source === 'event'
? t('monitoring.unified.unhandled')
: executionProcessorLabel(row)}
</span>
</div>
<span className="truncate text-xs text-muted-foreground">
@@ -137,6 +149,16 @@ export default function ExecutionTable({
</span>
</div>
</TableCell>
<TableCell className="max-w-xs">
<div className="line-clamp-2 break-words text-sm">
{row.input_preview || '—'}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{[row.bot_name, row.user_name || row.user_id]
.filter(Boolean)
.join(' · ')}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={executionStatusVariant(row.status_group)}>
@@ -0,0 +1,178 @@
import { MessageContentRenderer } from '../MessageContentRenderer';
// Keep arbitrary event properties, including false and zero. Empty transport
// fields are not messages. This also applies to historical serialized payloads.
export function hasExecutionValue(value: unknown): boolean {
if (value == null) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.some(hasExecutionValue);
if (typeof value === 'object')
return Object.values(value).some(hasExecutionValue);
return true;
}
function textProjection(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(textProjection).join('');
if (value && typeof value === 'object') {
const item = value as Record<string, unknown>;
if (item.type === 'text' || item.type === 'Plain')
return typeof item.text === 'string' ? item.text : '';
}
return '';
}
const messageTypes = new Set([
'Plain',
'At',
'AtAll',
'Image',
'File',
'Voice',
'Quote',
]);
const componentFields: Record<string, string[]> = {
Plain: ['text'],
At: ['target', 'display'],
AtAll: [],
Image: ['url', 'base64', 'imageId'],
File: ['name', 'id', 'url', 'size'],
Voice: ['url', 'base64', 'voiceId', 'length'],
Quote: ['origin'],
};
export function ExecutionValue({ value }: { value: unknown }) {
if (!hasExecutionValue(value)) return null;
if (typeof value === 'string') {
// Do not reinterpret ordinary strings such as "false", "0", or quoted
// user text. Only historical structured JSON needs decoding.
if (/^\s*[\[{]/.test(value)) {
try {
return <ExecutionValue value={JSON.parse(value)} />;
} catch {
/* Text. */
}
}
return (
<pre className="whitespace-pre-wrap break-words font-sans text-sm [overflow-wrap:anywhere]">
{value}
</pre>
);
}
if (Array.isArray(value))
return (
<div className="space-y-2">
{value.map((item, index) => (
<ExecutionValue key={index} value={item} />
))}
</div>
);
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.type === 'string' && messageTypes.has(record.type)) {
const fields = ['type', ...componentFields[record.type]];
return (
<>
<MessageContentRenderer
content={JSON.stringify([record])}
maxLines={12}
/>
<ExecutionValue
value={Object.fromEntries(
Object.entries(record).filter(([key]) => !fields.includes(key)),
)}
/>
</>
);
}
if (record.type === 'text') {
const rest = Object.fromEntries(
Object.entries(record).filter(
([key, item]) =>
key !== 'type' && key !== 'text' && hasExecutionValue(item),
),
);
return (
<>
<ExecutionValue value={record.text} />
<ExecutionValue value={rest} />
</>
);
}
if (['image', 'image_url', 'image_base64'].includes(String(record.type))) {
const image = record.image_url;
const url =
typeof image === 'string'
? image
: image && typeof image === 'object'
? (image as { url?: string }).url
: typeof record.image_base64 === 'string'
? record.image_base64.startsWith('data:')
? record.image_base64
: `data:image/png;base64,${record.image_base64}`
: null;
if (url)
return (
<>
<MessageContentRenderer
content={JSON.stringify([{ type: 'Image', url }])}
maxLines={12}
/>
<ExecutionValue
value={Object.fromEntries(
Object.entries(record).filter(
([key]) =>
!['type', 'image_url', 'image_base64'].includes(key),
),
)}
/>
</>
);
}
if (['file', 'file_url', 'file_base64'].includes(String(record.type))) {
const name = record.file_name ?? record.name;
return (
<>
<MessageContentRenderer
content={JSON.stringify([{ type: 'File', name }])}
maxLines={12}
/>
<ExecutionValue
value={Object.fromEntries(
Object.entries(record).filter(
([key]) =>
!['type', 'file_name', 'name', 'file_base64'].includes(key),
),
)}
/>
</>
);
}
// Only equal text projections are aliases. Never deduplicate distinct
// records, or hide different text and structured content from one event.
const bodyKeys = ['chain', 'message_chain', 'contents', 'content'];
const bodyKey = bodyKeys.find((key) => hasExecutionValue(record[key]));
const body = bodyKey ? record[bodyKey] : undefined;
const duplicateText =
bodyKey &&
typeof record.text === 'string' &&
record.text === textProjection(body);
const entries = Object.entries(record).filter(
([key, item]) =>
hasExecutionValue(item) && !(key === 'text' && duplicateText),
);
return (
<div className="space-y-2">
{entries.map(([key, item]) => (
<div key={key}>
{!['text', ...bodyKeys, 'attachments'].includes(key) && (
<div className="text-xs text-muted-foreground">{key}</div>
)}
<ExecutionValue value={item} />
</div>
))}
</div>
);
}
return <span className="text-sm">{String(value)}</span>;
}
@@ -8,12 +8,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { backendClient } from '@/app/infra/http';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { TimeRangeOption } from '../../types/monitoring';
import type {
ExecutionModeFilter,
ExecutionSourceFilter,
} from '@/app/infra/entities/api/monitoring-executions';
import type { ExecutionModeFilter } from '@/app/infra/entities/api/monitoring-executions';
interface MonitoringFiltersProps {
selectedBots: string[];
@@ -22,8 +19,6 @@ 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;
@@ -39,14 +34,25 @@ const STATUS_GROUP_OPTIONS = [
'ignored',
];
const STATUS_COLORS: Record<string, string> = {
completed: 'text-emerald-700 dark:text-emerald-400',
failed: 'text-red-600 dark:text-red-400',
running: 'text-blue-600 dark:text-blue-400',
queued: 'text-amber-700 dark:text-amber-400',
cancelled: 'text-slate-600 dark:text-slate-400',
ignored: 'text-muted-foreground',
};
interface Bot {
uuid: string;
name: string;
iconURL: string;
}
interface Pipeline {
interface Processor {
uuid: string;
name: string;
emoji?: string;
}
export default function MonitoringFilters({
@@ -56,21 +62,22 @@ export default function MonitoringFilters({
onBotsChange,
onPipelinesChange,
onTimeRangeChange,
source,
onSourceChange,
mode,
onModeChange,
statusGroup,
onStatusGroupChange,
}: MonitoringFiltersProps) {
const { t } = useTranslation();
const workspace = useCurrentWorkspace()?.workspace.uuid;
const [bots, setBots] = useState<Bot[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [processors, setProcessors] = useState<Processor[]>([]);
const [loadingBots, setLoadingBots] = useState(false);
const [loadingPipelines, setLoadingPipelines] = useState(false);
const [loadingProcessors, setLoadingProcessors] = useState(false);
// Fetch bots list
useEffect(() => {
let active = true;
setBots([]);
const fetchBots = async () => {
setLoadingBots(true);
try {
@@ -78,41 +85,57 @@ export default function MonitoringFilters({
// Filter out bots without uuid and map to local Bot interface
const validBots = (response.bots || [])
.filter((bot): bot is typeof bot & { uuid: string } => !!bot.uuid)
.map((bot) => ({ uuid: bot.uuid, name: bot.name }));
setBots(validBots);
.map((bot) => ({
uuid: bot.uuid,
name: bot.name,
iconURL: backendClient.getAdapterIconURL(bot.adapter),
}));
if (active) setBots(validBots);
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoadingBots(false);
if (active) setLoadingBots(false);
}
};
fetchBots();
}, []);
return () => {
active = false;
};
}, [workspace]);
// Fetch pipelines list
// Fetch all processor kinds from the same catalog as the sidebar
useEffect(() => {
const fetchPipelines = async () => {
setLoadingPipelines(true);
let active = true;
setProcessors([]);
const fetchProcessors = async () => {
setLoadingProcessors(true);
try {
const response = await backendClient.getPipelines();
// Filter out pipelines without uuid and map to local Pipeline interface
const validPipelines = (response.pipelines || [])
const response = await backendClient.getAgents();
// Preserve the custom icon displayed to the left of the sidebar name.
const validProcessors = (response.agents || [])
.filter(
(pipeline): pipeline is typeof pipeline & { uuid: string } =>
!!pipeline.uuid,
)
.map((pipeline) => ({ uuid: pipeline.uuid, name: pipeline.name }));
setPipelines(validPipelines);
.map((pipeline) => ({
uuid: pipeline.uuid,
name: pipeline.name,
emoji: pipeline.emoji,
}));
if (active) setProcessors(validProcessors);
} catch (error) {
console.error('Failed to fetch pipelines:', error);
console.error('Failed to fetch processors:', error);
} finally {
setLoadingPipelines(false);
if (active) setLoadingProcessors(false);
}
};
fetchPipelines();
}, []);
fetchProcessors();
return () => {
active = false;
};
}, [workspace]);
const handleBotChange = (value: string) => {
if (value === 'all') {
@@ -135,41 +158,104 @@ 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)
}
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{/* Bot Filter */}
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-bot"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.filters.bot')}
</Label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger
id="monitoring-filter-bot"
className="h-9 w-full min-w-0"
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.execution.filters.sourceAll')}
<SelectValue
placeholder={
loadingBots
? t('monitoring.filters.loading')
: t('monitoring.filters.selectBot')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allBots')}
</SelectItem>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
<span className="flex min-w-0 items-center gap-2">
<img
src={bot.iconURL}
alt=""
className="size-4 shrink-0 rounded"
/>
<span className="truncate">{bot.name}</span>
</span>
</SelectItem>
<SelectItem value="agent">
{t('monitoring.execution.filters.sourceAgent')}
))}
</SelectContent>
</Select>
</div>
{/* Processor Filter */}
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-processor"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.filters.processor')}
</Label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
disabled={loadingProcessors}
>
<SelectTrigger
id="monitoring-filter-processor"
className="h-9 w-full min-w-0"
>
<SelectValue
placeholder={
loadingProcessors
? t('monitoring.filters.loading')
: t('monitoring.filters.selectProcessor')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allProcessors')}
</SelectItem>
{processors.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
<span className="flex min-w-0 items-center gap-2">
{pipeline.emoji && (
<span className="shrink-0 text-sm" aria-hidden="true">
{pipeline.emoji}
</span>
)}
<span className="truncate">{pipeline.name}</span>
</span>
</SelectItem>
<SelectItem value="pipeline">
{t('monitoring.execution.filters.sourcePipeline')}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
))}
</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">
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-mode"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.execution.filters.mode')}
</Label>
<Select
@@ -178,7 +264,10 @@ export default function MonitoringFilters({
onModeChange(value as ExecutionModeFilter)
}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectTrigger
id="monitoring-filter-mode"
className="h-9 w-full min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -198,15 +287,21 @@ export default function MonitoringFilters({
{/* 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">
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-status"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.execution.filters.status')}
</Label>
<Select
value={statusGroup ?? 'all'}
onValueChange={(value) => onStatusGroupChange(value)}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectTrigger
id="monitoring-filter-status"
className="h-9 w-full min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -215,7 +310,15 @@ export default function MonitoringFilters({
</SelectItem>
{STATUS_GROUP_OPTIONS.map((group) => (
<SelectItem key={group} value={group}>
{t(`monitoring.execution.status.${group}`)}
<span
className={`flex items-center gap-2 ${STATUS_COLORS[group]}`}
>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full bg-current"
/>
{t(`monitoring.execution.status.${group}`)}
</span>
</SelectItem>
))}
</SelectContent>
@@ -223,77 +326,19 @@ export default function MonitoringFilters({
</div>
)}
{/* Bot Filter */}
<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.filters.bot')}
</Label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue
placeholder={
loadingBots
? t('monitoring.filters.loading')
: t('monitoring.filters.selectBot')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allBots')}
</SelectItem>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Pipeline Filter */}
<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.filters.pipeline')}
</Label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
disabled={loadingPipelines}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue
placeholder={
loadingPipelines
? t('monitoring.filters.loading')
: t('monitoring.filters.selectPipeline')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allPipelines')}
</SelectItem>
{pipelines.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
{pipeline.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Time Range Filter */}
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-time"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.filters.timeRange')}
</Label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="h-9 w-full sm:w-[150px]">
<SelectTrigger
id="monitoring-filter-time"
className="h-9 w-full min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -2,19 +2,12 @@ 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';
@@ -63,7 +56,7 @@ export default function ExecutionOverviewCards({
>
{!!overview?.debug && (
<div className="text-xs text-muted-foreground">
{t('monitoring.execution.cards.hiddenDebug', {
{t('monitoring.execution.cards.debugRuns', {
count: overview.debug,
})}
</div>
@@ -72,6 +65,7 @@ export default function ExecutionOverviewCards({
<MetricCard
label={t('monitoring.execution.cards.successRate')}
description={t('monitoring.execution.cards.successRateHint')}
icon={<CheckCircle2 className="h-4 w-4" />}
accent="#3b82f6"
loading={loading}
@@ -82,24 +76,6 @@ export default function ExecutionOverviewCards({
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
@@ -142,6 +118,7 @@ export default function ExecutionOverviewCards({
<MetricCard
label={t('monitoring.execution.cards.latency')}
description={t('monitoring.execution.cards.latencyHint')}
icon={<Timer className="h-4 w-4" />}
accent="#f59e0b"
loading={loading}
@@ -175,6 +152,7 @@ export default function ExecutionOverviewCards({
<MetricCard
label={t('monitoring.execution.cards.tokens')}
description={t('monitoring.execution.cards.tokensHint')}
icon={<Zap className="h-4 w-4" />}
accent="#10b981"
loading={loading}
@@ -4,10 +4,18 @@ import React from 'react';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export interface MetricCardProps {
/** Small label, sitting next to the icon. */
label: string;
description?: 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. */
@@ -32,6 +40,7 @@ export interface MetricCardProps {
*/
export function MetricCard({
label,
description,
value,
icon,
hint,
@@ -56,9 +65,28 @@ export function MetricCard({
{icon}
</span>
) : null}
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
{description && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={label}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="max-w-[min(20rem,calc(100vw-2rem))] text-sm leading-relaxed"
>
{description}
</TooltipContent>
</Tooltip>
)}
</div>
{action}
</div>
@@ -4,6 +4,8 @@ import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
import { parseUTCTimestamp } from '../utils/dateUtils';
interface UseFeedbackDataParams {
mode?: 'all' | 'real' | 'debug';
statusGroup?: string;
botIds?: string[];
pipelineIds?: string[];
startTime?: string;
@@ -60,6 +62,9 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
const fetchStats = useCallback(async () => {
try {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
if (params.statusGroup && params.statusGroup !== 'all')
queryParams.append('status', params.statusGroup);
if (params.botIds) {
params.botIds.forEach((id) => queryParams.append('botId', id));
}
@@ -99,7 +104,14 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
} catch (err) {
console.error('Failed to fetch feedback stats:', err);
}
}, [params.botIds, params.pipelineIds, params.startTime, params.endTime]);
}, [
params.mode,
params.statusGroup,
params.botIds,
params.pipelineIds,
params.startTime,
params.endTime,
]);
const fetchFeedback = useCallback(async () => {
setLoading(true);
@@ -107,6 +119,9 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
try {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
if (params.statusGroup && params.statusGroup !== 'all')
queryParams.append('status', params.statusGroup);
if (params.botIds) {
params.botIds.forEach((id) => queryParams.append('botId', id));
}
@@ -54,6 +54,11 @@ export function useMonitoringData(filterState: FilterState) {
);
const response = await backendClient.getMonitoringData({
mode: filterState.mode,
status:
filterState.statusGroup && filterState.statusGroup !== 'all'
? [filterState.statusGroup]
: undefined,
botId:
filterState.selectedBots.length > 0
? filterState.selectedBots
@@ -390,6 +395,8 @@ export function useMonitoringData(filterState: FilterState) {
if (isCurrent()) setLoading(false);
}
}, [
filterState.mode,
filterState.statusGroup,
filterState.timeRange,
filterState.customDateRange,
filterState.selectedBots,
@@ -408,6 +415,8 @@ export function useMonitoringData(filterState: FilterState) {
}, [
selectedBotsStr,
selectedPipelinesStr,
filterState.mode,
filterState.statusGroup,
filterState.timeRange,
customDateRangeStr,
workspaceUuid,
@@ -1,65 +1,128 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCurrentWorkspace } from '@/app/infra/http';
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
import { getPresetDateRange } from '../utils/dateUtils';
import type { ExecutionModeFilter } from '@/app/infra/entities/api/monitoring-executions';
/**
* Custom hook for managing monitoring filters
*/
export function useMonitoringFilters() {
const [searchParams] = useSearchParams();
const defaults = (): FilterState => ({
selectedBots: [],
selectedPipelines: [],
timeRange: 'last24Hours',
customDateRange: null,
mode: 'all',
statusGroup: 'all',
});
const timeRanges = [
'lastHour',
'last6Hours',
'last24Hours',
'last7Days',
'last30Days',
'custom',
];
const statuses = [
'all',
'completed',
'failed',
'running',
'queued',
'cancelled',
'ignored',
];
// Initialize filters from URL params
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
const botId = searchParams.get('botId');
return botId ? [botId] : [];
});
const [selectedPipelines, setSelectedPipelines] = useState<string[]>(() => {
const pipelineId = searchParams.get('pipelineId');
return pipelineId ? [pipelineId] : [];
});
const [timeRange, setTimeRange] = useState<TimeRangeOption>('last24Hours');
const [customDateRange, setCustomDateRange] = useState<DateRange | null>(
null,
);
// Get the active date range (either preset or custom)
const getActiveDateRange = (): DateRange | null => {
if (timeRange === 'custom' && customDateRange) {
return customDateRange;
function restore(key: string): FilterState {
const state = defaults();
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null');
if (!saved || typeof saved !== 'object') return state;
for (const name of ['selectedBots', 'selectedPipelines'] as const) {
if (Array.isArray(saved[name]))
state[name] = saved[name].filter(
(id: unknown) => typeof id === 'string' && id.length > 0,
);
}
return getPresetDateRange(timeRange);
};
if (timeRanges.includes(saved.timeRange)) state.timeRange = saved.timeRange;
if (['all', 'real', 'debug'].includes(saved.mode)) state.mode = saved.mode;
if (statuses.includes(saved.statusGroup))
state.statusGroup = saved.statusGroup;
if (saved.customDateRange) {
const from = new Date(saved.customDateRange.from);
const to = new Date(saved.customDateRange.to);
if (
Number.isFinite(from.getTime()) &&
Number.isFinite(to.getTime()) &&
from <= to
)
state.customDateRange = { from, to };
}
if (state.timeRange === 'custom' && !state.customDateRange)
state.timeRange = 'last24Hours';
} catch {
// Invalid or unavailable browser storage must not prevent monitoring.
}
return state;
}
// Reset all filters
/** The page is keyed by Workspace so saved resource IDs never cross scopes. */
export function useMonitoringFilters() {
const workspace = useCurrentWorkspace()?.workspace.uuid;
const key = `monitoring.filters.v1:${workspace ?? 'pending'}`;
const [searchParams, setSearchParams] = useSearchParams();
const [filterState, setFilters] = useState<FilterState>(() => {
const state = workspace ? restore(key) : defaults();
// Explicit links take precedence over saved resource filters.
if (searchParams.has('botId'))
state.selectedBots = searchParams.getAll('botId').filter(Boolean);
if (searchParams.has('pipelineId'))
state.selectedPipelines = searchParams
.getAll('pipelineId')
.filter(Boolean);
return state;
});
useEffect(() => {
if (!workspace) return;
try {
localStorage.setItem(key, JSON.stringify(filterState));
} catch {
/* Storage may be disabled. */
}
}, [key, workspace, filterState]);
const update = (value: Partial<FilterState>) =>
setFilters((current) => ({ ...current, ...value }));
const clearLinkedFilter = (name: string) => {
if (!searchParams.has(name)) return;
const next = new URLSearchParams(searchParams);
next.delete(name);
setSearchParams(next, { replace: true });
};
const resetFilters = () => {
setSelectedBots([]);
setSelectedPipelines([]);
setTimeRange('last24Hours');
setCustomDateRange(null);
setFilters(defaults());
const next = new URLSearchParams(searchParams);
next.delete('botId');
next.delete('pipelineId');
setSearchParams(next, { replace: true });
};
// Get the current filter state
const filterState: FilterState = {
selectedBots,
selectedPipelines,
timeRange,
customDateRange,
};
return {
selectedBots,
setSelectedBots,
selectedPipelines,
setSelectedPipelines,
timeRange,
setTimeRange,
customDateRange,
setCustomDateRange,
getActiveDateRange,
resetFilters,
...filterState,
filterState,
setSelectedBots: (selectedBots: string[]) => {
update({ selectedBots });
clearLinkedFilter('botId');
},
setSelectedPipelines: (selectedPipelines: string[]) => {
update({ selectedPipelines });
clearLinkedFilter('pipelineId');
},
setTimeRange: (timeRange: TimeRangeOption) => update({ timeRange }),
setCustomDateRange: (customDateRange: DateRange | null) =>
update({ customDateRange }),
setExecutionMode: (mode: ExecutionModeFilter) => update({ mode }),
setExecutionStatus: (statusGroup: string) => update({ statusGroup }),
getActiveDateRange: () =>
filterState.timeRange === 'custom'
? filterState.customDateRange
: getPresetDateRange(filterState.timeRange),
resetFilters,
};
}
+67 -134
View File
@@ -16,7 +16,7 @@ import {
ChevronDown,
ExternalLink,
RefreshCw,
MessageSquare,
RotateCcw,
Sparkles,
CheckCircle2,
Activity,
@@ -30,20 +30,14 @@ import { ExportDropdown } from './components/ExportDropdown';
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData';
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 ExecutionDetailSheet, {
type ExecutionSelection,
} 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 { resolveCallTarget } from './utils/callLinks';
import { cn } from '@/lib/utils';
import { resolveMonitoringWindow } from './utils/dateUtils';
import { LoadingPage } from '@/components/ui/loading-spinner';
@@ -54,20 +48,25 @@ function MonitoringPageContent() {
const currentWorkspace = useCurrentWorkspace();
const canExport =
currentWorkspace?.permissions.includes('data.export') ?? false;
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
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 {
filterState,
setSelectedBots,
setSelectedPipelines,
setTimeRange,
setExecutionMode,
setExecutionStatus,
resetFilters,
} = useMonitoringFilters();
const executionMode = filterState.mode ?? 'all';
const executionStatus = filterState.statusGroup ?? 'all';
const { data, loading, error, refetch } = useMonitoringData({
...filterState,
mode: executionMode,
statusGroup: executionStatus,
});
const [executionOffset, setExecutionOffset] = useState(0);
const [selectedExecution, setSelectedExecution] =
useState<ExecutionRow | null>(null);
useState<ExecutionSelection | null>(null);
const EXECUTIONS_PAGE_SIZE = 50;
const {
@@ -79,7 +78,7 @@ function MonitoringPageContent() {
selectedBots: filterState.selectedBots,
selectedPipelines: filterState.selectedPipelines,
selectedAgents: [],
source: executionSource,
source: 'all',
mode: executionMode,
statusGroup: executionStatus,
timeRange: filterState.timeRange,
@@ -92,7 +91,6 @@ function MonitoringPageContent() {
useEffect(() => {
setExecutionOffset(0);
}, [
executionSource,
executionMode,
executionStatus,
filterState.selectedBots,
@@ -121,6 +119,8 @@ function MonitoringPageContent() {
stats: feedbackStats,
loading: feedbackLoading,
} = useFeedbackData({
mode: executionMode,
statusGroup: executionStatus,
botIds:
filterState.selectedBots.length > 0
? filterState.selectedBots
@@ -141,44 +141,16 @@ function MonitoringPageContent() {
setFeedbackRefreshKey((k) => k + 1);
}, [refetch, refetchExecutions]);
const conversationTurns = useMemo(
() =>
buildConversationTurns(
data?.messages || [],
data?.llmCalls || [],
data?.errors || [],
data?.toolCalls || [],
),
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
);
// State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
// State for controlled tabs
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) => {
setActiveTab('messages');
setTimeout(() => {
const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
setExpandedTurnId(turn?.id ?? messageId);
}, 100);
};
const toggleTurnExpand = (turnId: string) => {
setExpandedTurnId((current) => (current === turnId ? null : turnId));
// Resolve the owning record server-side, including records outside this page.
const jumpToMessage = (identifier: string) => {
setActiveTab('executions');
setSelectedExecution({ source: 'auto', id: identifier });
};
const toggleErrorExpand = (errorId: string) => {
@@ -194,7 +166,7 @@ function MonitoringPageContent() {
{/* Filters and Refresh Button - Sticky */}
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<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">
<Card className="flex flex-col gap-4 p-4 xl:flex-row xl:items-end">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
@@ -202,20 +174,22 @@ 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">
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-3 xl:border-l xl:border-t-0 xl:pl-4 xl:pt-0 [&_button]:h-9">
<Button variant="outline" size="sm" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('monitoring.filters.reset')}
</Button>
{canExport && <ExportDropdown filterState={filterState} />}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
className="flex-1 shadow-sm sm:flex-shrink-0 sm:flex-none"
className="shadow-sm"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t('monitoring.refreshData')}
@@ -258,14 +232,6 @@ function MonitoringPageContent() {
className="text-sm text-muted-foreground space-y-1"
role="status"
>
{data.totalCount.messages > data.messages.length && (
<p>
{t('monitoring.partialMessages', {
shown: data.messages.length,
total: data.totalCount.messages,
})}
</p>
)}
{data.totalCount.llmCalls + data.totalCount.embeddingCalls >
data.modelCalls.length && (
<p>
@@ -308,9 +274,7 @@ function MonitoringPageContent() {
<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>
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.modelCalls')}
</TabsTrigger>
@@ -413,30 +377,6 @@ function MonitoringPageContent() {
)}
</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
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{!loading && (!data || conversationTurns.length === 0) && (
<TabState
icon={<MessageSquare className="h-12 w-12" />}
title={t('monitoring.messageList.noMessages')}
/>
)}
</div>
</TabsContent>
<TabsContent
value="modelCalls"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
@@ -453,19 +393,9 @@ function MonitoringPageContent() {
// 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 target = call.messageId;
const openTarget = () => {
if (!target) return;
if (target.kind === 'execution') {
setActiveTab('executions');
setSelectedExecution(target.row);
return;
}
jumpToMessage(target.id);
if (target) jumpToMessage(target);
};
return (
<Card
@@ -504,31 +434,30 @@ function MonitoringPageContent() {
{/* 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>
)}
{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
@@ -715,6 +644,8 @@ function MonitoringPageContent() {
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<TokenMonitoring
mode={executionMode}
statusGroup={executionStatus}
botIds={
filterState.selectedBots.length > 0
? filterState.selectedBots
@@ -934,6 +865,7 @@ function MonitoringPageContent() {
{/* Execution trace drawer */}
<ExecutionDetailSheet
row={selectedExecution}
onSelect={setSelectedExecution}
onClose={() => setSelectedExecution(null)}
/>
</div>
@@ -941,9 +873,10 @@ function MonitoringPageContent() {
}
export default function MonitoringPage() {
const workspace = useCurrentWorkspace()?.workspace.uuid;
return (
<Suspense fallback={<LoadingPage />}>
<MonitoringPageContent />
<MonitoringPageContent key={workspace ?? 'pending'} />
</Suspense>
);
}
@@ -164,6 +164,8 @@ export interface OverviewMetrics {
}
export interface FilterState {
mode?: 'all' | 'real' | 'debug';
statusGroup?: string;
selectedBots: string[];
selectedPipelines: string[];
timeRange: TimeRangeOption;
@@ -1,6 +1,4 @@
import type { ProcessorRun, ProcessorRunEvent } from './index';
export type ExecutionSource = 'agent' | 'pipeline';
export type ExecutionSource = 'agent' | 'pipeline' | 'event';
export type ExecutionSourceFilter = 'all' | ExecutionSource;
export type ExecutionModeFilter = 'all' | 'real' | 'debug';
@@ -24,6 +22,9 @@ export interface ExecutionRow {
| 'ignored'
| string;
title: string;
input_preview?: string;
event_type?: string;
status_reason?: string | null;
target_kind: 'agent' | 'pipeline' | 'processor' | string;
target_id: string | null;
target_name: string | null;
@@ -117,20 +118,48 @@ export interface ExecutionCallRow {
[key: string]: unknown;
}
export interface AgentExecutionDetail {
source: 'agent';
run: ProcessorRun;
events: ProcessorRunEvent[];
export type ExecutionSection =
| 'inputs'
| 'outputs'
| 'deliveries'
| 'conversation'
| 'related'
| 'events'
| 'llm_calls'
| 'tool_calls'
| 'errors';
export interface ExecutionDetailItem {
id: string | number;
timestamp_ms?: number | null;
role?: string;
origin?: string;
event_type?: string;
actor_name?: string;
actor_id?: string;
content?: unknown;
attachments?: unknown[];
status?: string;
type?: string;
sequence?: number;
data?: Record<string, unknown>;
metadata?: Record<string, unknown>;
delivery?: Record<string, unknown>;
[key: string]: unknown;
}
export interface ExecutionDetailPage {
items: ExecutionDetailItem[];
has_more: boolean;
next_cursor: number | null;
next_offset: number;
}
export interface PipelineExecutionDetail {
source: 'pipeline';
message: PipelineMessageRow;
llm_calls: ExecutionCallRow[];
tool_calls: ExecutionCallRow[];
errors: ExecutionCallRow[];
export interface ExecutionDetail {
source: ExecutionSource;
row: ExecutionRow;
pages: Partial<Record<ExecutionSection, ExecutionDetailPage>>;
legacy_context: boolean;
}
export type ExecutionDetail = AgentExecutionDetail | PipelineExecutionDetail;
export type AgentExecutionDetail = ExecutionDetail & { source: 'agent' };
export type PipelineExecutionDetail = ExecutionDetail & { source: 'pipeline' };
+14 -2
View File
@@ -1980,6 +1980,8 @@ export class BackendClient extends BaseHttpClient {
// ============ Monitoring API ============
public getMonitoringData(params: {
mode?: ExecutionModeFilter;
status?: string[];
botId?: string[];
pipelineId?: string[];
startTime?: string;
@@ -2103,6 +2105,8 @@ export class BackendClient extends BaseHttpClient {
};
}> {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
params.status?.forEach((status) => queryParams.append('status', status));
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
@@ -2170,11 +2174,15 @@ export class BackendClient extends BaseHttpClient {
}
public getExecutionDetail(
source: ExecutionSource,
source: ExecutionSource | 'auto',
executionId: string,
options: { section?: string; offset?: number; limit?: number } = {},
): Promise<ExecutionDetail> {
const params = new URLSearchParams(
Object.entries(options).map(([key, value]) => [key, String(value)]),
);
return this.get(
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}`,
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}?${params}`,
);
}
@@ -2207,6 +2215,8 @@ export class BackendClient extends BaseHttpClient {
}
public getTokenStatistics(params: {
mode?: ExecutionModeFilter;
status?: string[];
botId?: string[];
pipelineId?: string[];
startTime?: string;
@@ -2247,6 +2257,8 @@ export class BackendClient extends BaseHttpClient {
bucket: string;
}> {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
params.status?.forEach((status) => queryParams.append('status', status));
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
+36 -5
View File
@@ -2456,6 +2456,30 @@ const enUS = {
},
},
monitoring: {
unified: {
inputs: 'Event / input',
outputs: 'Generated results',
deliveries: 'Delivery records',
conversation: 'Conversation context',
related: 'Related executions',
events: 'Execution events',
llm_calls: 'Model calls',
tool_calls: 'Tool calls',
errors: 'Errors',
payload: 'Details',
metadata: 'Technical details',
loadMore: 'Load more',
noRecords: 'No records',
contextHint:
'Conversation context is not necessarily part of this execution.',
unhandled: 'No processor run',
roles: {
user: 'User',
assistant: 'Assistant',
system: 'System',
tool: 'Tool',
},
},
execution: {
title: 'Executions',
subtitle:
@@ -2499,24 +2523,28 @@ const enUS = {
loading: 'Loading executions...',
triggeredBy: 'Triggered by {{trigger}}',
cards: {
latencyHint:
'P50/P95: about 50%/95% of runs finish within this time. Based on recent completed Agent runs, excluding queue time.',
tokensHint:
'Only recorded usage is included. Totals may be incomplete when models do not return usage.',
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',
'Successes ÷ (successes + failures). In-progress, ignored and cancelled runs are excluded.',
denominator: '{{completed}} succeeded out of {{denominator}} finished',
running: 'Running',
queued: 'Queued',
waiting: 'Waiting input',
failed: 'Failed',
ignored: 'Ignored',
latency: 'Duration P50 / P95',
latency: 'Execution time',
p50: 'P50',
p95: 'P95',
sample: 'sample of {{count}} runs',
sample: 'Based on {{count}} runs',
tokens: 'Tokens',
coverage: 'usage on {{withUsage}}/{{calls}} calls',
hiddenDebug: '{{count}} debug runs hidden',
debugRuns: 'Includes {{count}} debug runs',
},
detail: {
title: 'Execution',
@@ -2553,6 +2581,9 @@ const enUS = {
pipeline: 'Pipeline',
allBots: 'All Bots',
selectBot: 'Select Bot',
processor: 'Processor',
allProcessors: 'All Processors',
selectProcessor: 'Select Processor',
allPipelines: 'All Pipelines',
selectPipeline: 'Select Pipeline',
loading: 'Loading...',
+34 -3
View File
@@ -2508,6 +2508,30 @@ const esES = {
},
},
monitoring: {
unified: {
inputs: 'Evento y entrada',
outputs: 'Resultados generados',
deliveries: 'Registros de envío',
conversation: 'Contexto de conversación',
related: 'Ejecuciones relacionadas',
events: 'Eventos de ejecución',
llm_calls: 'Llamadas al modelo',
tool_calls: 'Llamadas a herramientas',
errors: 'Errores',
payload: 'Detalles',
metadata: 'Información técnica',
loadMore: 'Cargar más',
noRecords: 'Sin registros',
contextHint:
'Otros mensajes no pertenecen necesariamente a esta ejecución.',
unhandled: 'Sin ejecución',
roles: {
user: 'Usuario',
assistant: 'Asistente',
system: 'Sistema',
tool: 'Herramienta',
},
},
execution: {
title: 'Ejecuciones',
subtitle:
@@ -2551,24 +2575,28 @@ const esES = {
loading: 'Cargando ejecuciones...',
triggeredBy: 'Activada por {{trigger}}',
cards: {
latencyHint:
'P50/P95: cerca del 50%/95% termina dentro de este tiempo. Solo ejecuciones recientes de Agent completadas, sin espera en cola.',
tokensHint:
'Solo se suma el uso registrado. El total puede estar incompleto si el modelo no devuelve el uso.',
totalExecutions: 'Ejecuciones',
bySource: '{{agent}} de agentes · {{pipeline}} de pipelines',
successRate: 'Tasa de éxito de ejecución',
successRateHint:
'Completadas ÷ (completadas + fallidas). Se excluyen del denominador los eventos ignorados y las ejecuciones en curso.',
'Éxitos ÷ (éxitos + fallos). Se excluyen ejecuciones en curso, ignoradas y canceladas.',
denominator: '{{completed}} de {{denominator}} finalizadas',
running: 'En curso',
queued: 'En cola',
waiting: 'Esperando entrada',
failed: 'Fallida',
ignored: 'Ignorada',
latency: 'Duración P50 / P95',
latency: 'Tiempo de ejecución',
p50: 'P50',
p95: 'P95',
sample: 'muestra de {{count}} ejecuciones',
tokens: 'Tokens',
coverage: 'uso registrado en {{withUsage}}/{{calls}} llamadas',
hiddenDebug: '{{count}} ejecuciones de depuración ocultas',
debugRuns: 'Incluye {{count}} ejecuciones de depuración',
},
detail: {
title: 'Ejecución',
@@ -2606,6 +2634,9 @@ const esES = {
pipeline: 'Pipeline',
allBots: 'Todos los Bots',
selectBot: 'Seleccionar Bot',
processor: 'Procesador',
allProcessors: 'Todos los procesadores',
selectProcessor: 'Seleccionar procesador',
allPipelines: 'Todos los Pipelines',
selectPipeline: 'Seleccionar Pipeline',
loading: 'Cargando...',
+33 -3
View File
@@ -2472,6 +2472,29 @@ const jaJP = {
},
},
monitoring: {
unified: {
inputs: 'イベントと入力',
outputs: '生成結果',
deliveries: '送信記録',
conversation: '会話の前後関係',
related: '関連する実行',
events: '実行イベント',
llm_calls: 'モデル呼び出し',
tool_calls: 'ツール呼び出し',
errors: 'エラー',
payload: '詳細',
metadata: '技術情報',
loadMore: 'さらに読み込む',
noRecords: '記録なし',
contextHint: '会話内の他のメッセージがこの実行に属するとは限りません。',
unhandled: '処理なし',
roles: {
user: 'ユーザー',
assistant: 'アシスタント',
system: 'システム',
tool: 'ツール',
},
},
execution: {
title: '実行履歴',
subtitle:
@@ -2515,24 +2538,28 @@ const jaJP = {
loading: '実行履歴を読み込み中...',
triggeredBy: '{{trigger}} により開始',
cards: {
latencyHint:
'P50/P95:実行の約 50%/95% がこの時間内に完了します。最近の完了済み Agent 実行が対象で、キュー待ちは除きます。',
tokensHint:
'記録済みの使用量のみ集計します。モデルが使用量を返さない場合、合計は不完全になります。',
totalExecutions: '実行履歴',
bySource: 'エージェント {{agent}} 件 · パイプライン {{pipeline}} 件',
successRate: '実行成功率',
successRateHint:
'完了 ÷(完了+失敗)。無視されたイベントと実行中の処理は分母に含まれません。',
'成功数 ÷(成功数 + 失敗数)。実行中・無視・キャンセルは除きます。',
denominator: '終了済み {{denominator}} 件中 {{completed}} 件が完了',
running: '実行中',
queued: '待機中',
waiting: '入力待ち',
failed: '失敗',
ignored: '無視',
latency: '所要時間 P50 / P95',
latency: '実行時間',
p50: 'P50',
p95: 'P95',
sample: '{{count}} 件の実行を集計',
tokens: 'トークン',
coverage: '{{calls}} 回中 {{withUsage}} 回で使用量を取得',
hiddenDebug: 'デバッグ実行 {{count}} 件を非表示',
debugRuns: 'うちデバッグ実行 {{count}} 件',
},
detail: {
title: '実行',
@@ -2570,6 +2597,9 @@ const jaJP = {
pipeline: 'パイプライン',
allBots: 'すべてのボット',
selectBot: 'ボットを選択',
processor: 'プロセッサー',
allProcessors: 'すべてのプロセッサー',
selectProcessor: 'プロセッサーを選択',
allPipelines: 'すべてのパイプライン',
selectPipeline: 'パイプラインを選択',
loading: '読み込み中...',
+33 -3
View File
@@ -2480,6 +2480,29 @@ const ruRU = {
},
},
monitoring: {
unified: {
inputs: 'Событие и ввод',
outputs: 'Сгенерированные результаты',
deliveries: 'Записи отправки',
conversation: 'Контекст диалога',
related: 'Связанные запуски',
events: 'События выполнения',
llm_calls: 'Вызовы модели',
tool_calls: 'Вызовы инструментов',
errors: 'Ошибки',
payload: 'Подробности',
metadata: 'Технические сведения',
loadMore: 'Загрузить ещё',
noRecords: 'Нет записей',
contextHint: 'Другие сообщения могут не относиться к этому запуску.',
unhandled: 'Обработчик не запущен',
roles: {
user: 'Пользователь',
assistant: 'Ассистент',
system: 'Система',
tool: 'Инструмент',
},
},
execution: {
title: 'Выполнения',
subtitle:
@@ -2523,24 +2546,28 @@ const ruRU = {
loading: 'Загрузка выполнений...',
triggeredBy: 'Запущено: {{trigger}}',
cards: {
latencyHint:
'P50/P95: около 50%/95% запусков завершаются за это время. Только недавние завершённые запуски Agent, без ожидания в очереди.',
tokensHint:
'Учитывается только записанный расход. Итог может быть неполным, если модель не возвращает расход.',
totalExecutions: 'Выполнения',
bySource: 'Агенты: {{agent}} · Конвейеры: {{pipeline}}',
successRate: 'Доля успешных выполнений',
successRateHint:
'Завершённые ÷ (завершённые + неудачные). Пропущенные события и незавершённые выполнения не входят в знаменатель.',
'Успешные ÷ (успешные + неудачные). Текущие, пропущенные и отменённые запуски исключены.',
denominator: 'Успешно: {{completed}} из {{denominator}} завершившихся',
running: 'Выполняется',
queued: 'В очереди',
waiting: 'Ожидание ввода',
failed: 'Ошибка',
ignored: 'Пропущено',
latency: 'Длительность P50 / P95',
latency: 'Время выполнения',
p50: 'P50',
p95: 'P95',
sample: 'выборка: {{count}} выполнений',
tokens: 'Токены',
coverage: 'расход указан для {{withUsage}}/{{calls}} вызовов',
hiddenDebug: 'Скрыто отладочных выполнений: {{count}}',
debugRuns: 'В том числе отладочных выполнений: {{count}}',
},
detail: {
title: 'Выполнение',
@@ -2578,6 +2605,9 @@ const ruRU = {
pipeline: 'Конвейер',
allBots: 'Все боты',
selectBot: 'Выберите бота',
processor: 'Обработчик',
allProcessors: 'Все обработчики',
selectProcessor: 'Выберите обработчик',
allPipelines: 'Все конвейеры',
selectPipeline: 'Выберите конвейер',
loading: 'Загрузка...',
+33 -3
View File
@@ -2407,6 +2407,29 @@ const thTH = {
},
},
monitoring: {
unified: {
inputs: 'เหตุการณ์และข้อมูลเข้า',
outputs: 'ผลลัพธ์ที่สร้าง',
deliveries: 'บันทึกการส่ง',
conversation: 'บริบทการสนทนา',
related: 'การทำงานที่เกี่ยวข้อง',
events: 'เหตุการณ์การทำงาน',
llm_calls: 'การเรียกโมเดล',
tool_calls: 'การเรียกเครื่องมือ',
errors: 'ข้อผิดพลาด',
payload: 'รายละเอียด',
metadata: 'ข้อมูลทางเทคนิค',
loadMore: 'โหลดเพิ่มเติม',
noRecords: 'ไม่มีบันทึก',
contextHint: 'ข้อความอื่นอาจไม่ใช่ส่วนหนึ่งของการทำงานนี้',
unhandled: 'ไม่ได้เริ่มตัวประมวลผล',
roles: {
user: 'ผู้ใช้',
assistant: 'ผู้ช่วย',
system: 'ระบบ',
tool: 'เครื่องมือ',
},
},
execution: {
title: 'การทำงาน',
subtitle:
@@ -2450,11 +2473,15 @@ const thTH = {
loading: 'กำลังโหลดการทำงาน...',
triggeredBy: 'เรียกใช้โดย {{trigger}}',
cards: {
latencyHint:
'P50/P95: ประมาณ 50%/95% ทำงานเสร็จภายในเวลานี้ ใช้เฉพาะ Agent ที่เสร็จล่าสุด ไม่รวมเวลารอคิว',
tokensHint:
'รวมเฉพาะการใช้ที่บันทึกไว้ ผลรวมอาจไม่ครบหากโมเดลไม่ส่งข้อมูลการใช้กลับมา',
totalExecutions: 'การทำงาน',
bySource: 'เอเจนต์ {{agent}} · ไปป์ไลน์ {{pipeline}}',
successRate: 'อัตราการทำงานสำเร็จ',
successRateHint:
'เสร็จสิ้น ÷ (เสร็จสิ้น + ล้มเหลว) ไม่นับเหตุการณ์ที่ละเว้นและการทำงานที่ยังไม่สิ้นสุดในตัวหาร',
'สำเร็จ ÷ (สำเร็จ + ล้มเหลว) ไม่รวมงานที่กำลังทำงาน ถูกละเว้น หรือยกเลิก',
denominator:
'สำเร็จ {{completed}} จาก {{denominator}} รายการที่สิ้นสุดแล้ว',
running: 'กำลังทำงาน',
@@ -2462,13 +2489,13 @@ const thTH = {
waiting: 'รอการป้อนข้อมูล',
failed: 'ล้มเหลว',
ignored: 'ละเว้นแล้ว',
latency: 'ระยะเวลา P50 / P95',
latency: 'เวลาทำงาน',
p50: 'P50',
p95: 'P95',
sample: 'ตัวอย่างการทำงาน {{count}} ครั้ง',
tokens: 'โทเคน',
coverage: 'มีข้อมูลการใช้งาน {{withUsage}}/{{calls}} ครั้ง',
hiddenDebug: 'ซ่อนการทำงานดีบัก {{count}} ครั้ง',
debugRuns: 'รวมการทำงานดีบัก {{count}} ครั้ง',
},
detail: {
title: 'การทำงาน',
@@ -2505,6 +2532,9 @@ const thTH = {
pipeline: 'Pipeline',
allBots: 'Bot ทั้งหมด',
selectBot: 'เลือก Bot',
processor: 'ตัวประมวลผล',
allProcessors: 'ตัวประมวลผลทั้งหมด',
selectProcessor: 'เลือกตัวประมวลผล',
allPipelines: 'Pipeline ทั้งหมด',
selectPipeline: 'เลือก Pipeline',
loading: 'กำลังโหลด...',
+33 -3
View File
@@ -2442,6 +2442,29 @@ const viVN = {
},
},
monitoring: {
unified: {
inputs: 'Sự kiện và đầu vào',
outputs: 'Kết quả tạo ra',
deliveries: 'Bản ghi gửi',
conversation: 'Ngữ cảnh hội thoại',
related: 'Lượt chạy liên quan',
events: 'Sự kiện thực thi',
llm_calls: 'Lần gọi mô hình',
tool_calls: 'Lần gọi công cụ',
errors: 'Lỗi',
payload: 'Chi tiết',
metadata: 'Thông tin kỹ thuật',
loadMore: 'Tải thêm',
noRecords: 'Chưa có bản ghi',
contextHint: 'Các tin nhắn khác không nhất thiết thuộc lượt chạy này.',
unhandled: 'Chưa chạy bộ xử lý',
roles: {
user: 'Người dùng',
assistant: 'Trợ lý',
system: 'Hệ thống',
tool: 'Công cụ',
},
},
execution: {
title: 'Lượt thực thi',
subtitle:
@@ -2485,11 +2508,15 @@ const viVN = {
loading: 'Đang tải lượt thực thi...',
triggeredBy: 'Được kích hoạt bởi {{trigger}}',
cards: {
latencyHint:
'P50/P95: khoảng 50%/95% lượt chạy hoàn tất trong thời gian này. Chỉ tính Agent đã hoàn tất gần đây, không gồm thời gian xếp hàng.',
tokensHint:
'Chỉ cộng mức dùng đã ghi. Tổng có thể thiếu nếu mô hình không trả về dữ liệu sử dụng.',
totalExecutions: 'Lượt thực thi',
bySource: 'Tác nhân {{agent}} · Pipeline {{pipeline}}',
successRate: 'Tỷ lệ thực thi thành công',
successRateHint:
'Hoàn thành ÷ (hoàn thành + thất bại). Không tính sự kiện bị bỏ qua và lượt chưa kết thúc vào mẫu số.',
'Thành công ÷ (thành công + thất bại). Không tính lượt đang chạy, bỏ qua hoặc đã hủy.',
denominator:
'Thành công {{completed}} trong {{denominator}} lượt đã kết thúc',
running: 'Đang chạy',
@@ -2497,13 +2524,13 @@ const viVN = {
waiting: 'Đang chờ nhập liệu',
failed: 'Thất bại',
ignored: 'Đã bỏ qua',
latency: 'Thời lượng P50 / P95',
latency: 'Thời gian thực thi',
p50: 'P50',
p95: 'P95',
sample: 'Mẫu gồm {{count}} lượt thực thi',
tokens: 'Token',
coverage: 'Có dữ liệu sử dụng cho {{withUsage}}/{{calls}} lệnh gọi',
hiddenDebug: 'Đã ẩn {{count}} lượt gỡ lỗi',
debugRuns: 'Bao gồm {{count}} lượt gỡ lỗi',
},
detail: {
title: 'Lượt thực thi',
@@ -2540,6 +2567,9 @@ const viVN = {
pipeline: 'Pipeline',
allBots: 'Tất cả Bot',
selectBot: 'Chọn Bot',
processor: 'Bộ xử lý',
allProcessors: 'Tất cả bộ xử lý',
selectProcessor: 'Chọn bộ xử lý',
allPipelines: 'Tất cả Pipeline',
selectPipeline: 'Chọn Pipeline',
loading: 'Đang tải...',
+36 -6
View File
@@ -2308,6 +2308,29 @@ const zhHans = {
},
},
monitoring: {
unified: {
inputs: '事件与输入',
outputs: '生成结果',
deliveries: '发送记录',
conversation: '会话上下文',
related: '关联执行',
events: '执行事件',
llm_calls: '模型调用',
tool_calls: '工具调用',
errors: '错误',
payload: '查看详情',
metadata: '技术信息',
loadMore: '加载更多',
noRecords: '暂无记录',
contextHint: '会话中的其他消息不一定属于本次执行。',
unhandled: '未启动处理器',
roles: {
user: '用户',
assistant: '助手',
system: '系统',
tool: '工具',
},
},
execution: {
title: '执行记录',
subtitle: '每条入站事件及其触发的执行,覆盖 Agent 与 Pipeline。',
@@ -2350,24 +2373,28 @@ const zhHans = {
loading: '加载执行记录中...',
triggeredBy: '触发:{{trigger}}',
cards: {
latencyHint:
'P50/P95:约 50%/95% 的执行能在此时间内完成。仅统计最近已完成的 Agent 执行,不含排队时间。',
tokensHint:
'仅汇总已记录的用量;部分模型未返回用量时,总数可能不完整。',
totalExecutions: '执行总数',
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
successRate: '执行成功率',
successRateHint:
'已完成 ÷(已完成 + 失败)。已忽略事件与执行中的任务不计入分母。',
denominator: '{{completed}} / {{denominator}} 已结束',
'成功次数 ÷(成功 + 失败)。不计进行中、已忽略和已取消的执行。',
denominator: '{{denominator}} 次已结束,{{completed}} 次成功',
running: '运行中',
queued: '排队中',
waiting: '等待交互',
waiting: '等待输入',
failed: '失败',
ignored: '已忽略',
latency: '耗时 P50 / P95',
latency: '执行耗时',
p50: 'P50',
p95: 'P95',
sample: '样本 {{count}} 次',
sample: '根据 {{count}} 次执行计算',
tokens: 'Token',
coverage: '{{withUsage}}/{{calls}} 次调用有用量',
hiddenDebug: '已隐藏 {{count}} 条调试运行',
debugRuns: '其中 {{count}} 条为调试执行',
},
detail: {
title: '执行详情',
@@ -2404,6 +2431,9 @@ const zhHans = {
pipeline: '流水线',
allBots: '全部机器人',
selectBot: '选择机器人',
processor: '处理器',
allProcessors: '全部处理器',
selectProcessor: '选择处理器',
allPipelines: '全部流水线',
selectPipeline: '选择流水线',
loading: '加载中...',
+33 -3
View File
@@ -2309,6 +2309,29 @@ const zhHant = {
},
},
monitoring: {
unified: {
inputs: '事件與輸入',
outputs: '生成結果',
deliveries: '傳送記錄',
conversation: '對話上下文',
related: '關聯執行',
events: '執行事件',
llm_calls: '模型呼叫',
tool_calls: '工具呼叫',
errors: '錯誤',
payload: '查看詳情',
metadata: '技術資訊',
loadMore: '載入更多',
noRecords: '暫無記錄',
contextHint: '對話中的其他訊息不一定屬於本次執行。',
unhandled: '未啟動處理器',
roles: {
user: '使用者',
assistant: '助手',
system: '系統',
tool: '工具',
},
},
execution: {
title: '執行記錄',
subtitle: '每條入站事件及其觸發的執行,涵蓋 Agent 與 Pipeline。',
@@ -2351,24 +2374,28 @@ const zhHant = {
triggeredBy: '觸發:{{trigger}}',
loading: '載入執行記錄中...',
cards: {
latencyHint:
'P50/P95:約 50%/95% 的執行能在此時間內完成。僅統計最近已完成的 Agent 執行,不含排隊時間。',
tokensHint:
'僅加總已記錄的用量;部分模型未回傳用量時,總數可能不完整。',
totalExecutions: '執行總數',
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
successRate: '執行成功率',
successRateHint:
'已完成 ÷(已完成 + 失敗)。已忽略事件與執行中的任務不計入分母。',
'成功次數 ÷(成功 + 失敗)。不計進行中、已忽略及已取消的執行。',
denominator: '{{completed}} / {{denominator}} 已結束',
running: '執行中',
queued: '排隊中',
waiting: '等待互動',
failed: '失敗',
ignored: '已忽略',
latency: '耗時 P50 / P95',
latency: '執行耗時',
p50: 'P50',
p95: 'P95',
sample: '樣本 {{count}} 次',
tokens: 'Token',
coverage: '{{withUsage}}/{{calls}} 次呼叫有用量',
hiddenDebug: '已隱藏 {{count}} 筆偵錯執行',
debugRuns: '其中 {{count}} 筆為偵錯執行',
},
detail: {
title: '執行詳情',
@@ -2405,6 +2432,9 @@ const zhHant = {
pipeline: '流水線',
allBots: '全部機器人',
selectBot: '選擇機器人',
processor: '處理器',
allProcessors: '全部處理器',
selectProcessor: '選擇處理器',
allPipelines: '全部流水線',
selectPipeline: '選擇流水線',
loading: '載入中...',