mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
fix(acceptance): improve execution monitoring and processor navigation
This commit is contained in:
@@ -1418,6 +1418,28 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
end_time=end_time,
|
||||
debug=True if mode == 'debug' else False if mode == 'real' else None,
|
||||
)
|
||||
Message = persistence_monitoring.MonitoringMessage
|
||||
pipeline_timing = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(AgentRun.started_at, AgentRun.finished_at)
|
||||
.select_from(Message)
|
||||
.join(AgentRun, sqlalchemy.and_(
|
||||
AgentRun.workspace_id == workspace_uuid,
|
||||
AgentRun.run_id == Message.run_id,
|
||||
))
|
||||
.where(
|
||||
*pipeline_conditions,
|
||||
Message.status == 'success',
|
||||
AgentRun.started_at.is_not(None),
|
||||
AgentRun.finished_at.is_not(None),
|
||||
)
|
||||
.order_by(Message.timestamp.desc())
|
||||
.limit(self._detail_limit())
|
||||
)
|
||||
durations.extend(
|
||||
(row[1] - row[0]).total_seconds() * 1000
|
||||
for row in pipeline_timing.all() if row[0] and row[1]
|
||||
)
|
||||
durations.sort()
|
||||
pipeline_status_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(
|
||||
persistence_monitoring.MonitoringMessage.status,
|
||||
|
||||
@@ -201,10 +201,25 @@ class ExecutionDetailsMixin:
|
||||
# Absorbed steering events belong to an existing run, too.
|
||||
conditions = [
|
||||
EventLog.workspace_id == workspace,
|
||||
# The primary route is first; plugin subscriptions follow it.
|
||||
# Delivered pipeline ingress is not an unhandled execution, including
|
||||
# older queued/aggregated messages whose trace identity was lost.
|
||||
~sa.and_(
|
||||
sa.func.coalesce(
|
||||
self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_type')), ''
|
||||
) == 'pipeline',
|
||||
sa.func.coalesce(
|
||||
self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'status')), ''
|
||||
) == 'delivered',
|
||||
),
|
||||
~sa.exists(
|
||||
sa.select(AgentRun.id).where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
sa.or_(AgentRun.event_id == EventLog.event_id, AgentRun.run_id == EventLog.run_id),
|
||||
sa.or_(
|
||||
AgentRun.event_id == EventLog.event_id,
|
||||
AgentRun.run_id == EventLog.run_id,
|
||||
self._execution_json_text(AgentRun.metadata_json, 'ingress_event_id') == EventLog.event_id,
|
||||
),
|
||||
)
|
||||
),
|
||||
~sa.exists(
|
||||
@@ -259,6 +274,49 @@ class ExecutionDetailsMixin:
|
||||
}
|
||||
|
||||
async def enrich_execution_rows(self, workspace, items):
|
||||
pipeline_items = {item['id']: item for item in items if item.get('source') == 'pipeline'}
|
||||
if pipeline_items:
|
||||
# Pipeline rows own the UI identity; their linked Runner ledger owns
|
||||
# timing and usage. Read them in one batch for both list and detail.
|
||||
linked = await self._execution_query(
|
||||
workspace,
|
||||
sa.select(models.MonitoringMessage.id, AgentRun)
|
||||
.join(AgentRun, sa.and_(
|
||||
AgentRun.workspace_id == workspace,
|
||||
AgentRun.run_id == models.MonitoringMessage.run_id,
|
||||
))
|
||||
.where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace,
|
||||
models.MonitoringMessage.id.in_(pipeline_items),
|
||||
),
|
||||
)
|
||||
for message_id, run in linked.all():
|
||||
metrics = self._serialize_agent_execution(run)
|
||||
pipeline_items[message_id].update({
|
||||
key: metrics[key]
|
||||
for key in ('started_at_ms', 'finished_at_ms', 'duration_ms', 'usage', 'cost')
|
||||
})
|
||||
|
||||
calls = models.MonitoringLLMCall
|
||||
usage_rows = await self._execution_query(
|
||||
workspace,
|
||||
sa.select(
|
||||
calls.message_id,
|
||||
sa.func.sum(calls.input_tokens),
|
||||
sa.func.sum(calls.output_tokens),
|
||||
sa.func.sum(calls.total_tokens),
|
||||
).where(
|
||||
calls.workspace_uuid == workspace,
|
||||
calls.message_id.in_(pipeline_items),
|
||||
).group_by(calls.message_id),
|
||||
)
|
||||
for message_id, input_tokens, output_tokens, total_tokens in usage_rows.all():
|
||||
if not pipeline_items[message_id].get('usage'):
|
||||
pipeline_items[message_id]['usage'] = {
|
||||
'input_tokens': int(input_tokens or 0),
|
||||
'output_tokens': int(output_tokens or 0),
|
||||
'total_tokens': int(total_tokens or 0),
|
||||
}
|
||||
ids = {item['event_id'] for item in items if item.get('event_id')}
|
||||
if not ids:
|
||||
return
|
||||
|
||||
@@ -162,6 +162,13 @@ class MessageAggregator:
|
||||
if variables:
|
||||
enabled = False
|
||||
|
||||
# Preserve ingress identity across the queue and detached debounce task.
|
||||
from ..telemetry import trace
|
||||
|
||||
ingress_trace = trace.current()
|
||||
if ingress_trace is not None:
|
||||
variables = {**(variables or {}), '_ingress_event_id': ingress_trace.event_id}
|
||||
|
||||
if not enabled:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
|
||||
@@ -423,12 +423,13 @@ class RuntimePipeline:
|
||||
token = processing_mode.set('pipeline')
|
||||
# The Workspace-scoped opaque query uuid is the execution identity Space
|
||||
# shows for this lane; it is a stable UUID for every pooled query.
|
||||
execution_id = str(getattr(query, 'query_uuid', '') or '').strip() or str(query.query_id)
|
||||
ingress_event_id = (query.variables or {}).get('_ingress_event_id')
|
||||
execution_id = ingress_event_id or str(getattr(query, 'query_uuid', '') or '').strip() or str(query.query_id)
|
||||
# A lane with no inbound platform event in flight (WebChat, WebUI debug,
|
||||
# HTTP pipeline run) synthesizes its virtual inbound event here, so every
|
||||
# execution starts at an event boundary and the Runner is never the trace
|
||||
# origin. A lane running under a platform route nests inside that ingress.
|
||||
synthesizing = trace_mod.current() is None
|
||||
synthesizing = trace_mod.current() is None and not ingress_event_id
|
||||
try:
|
||||
with ingress(
|
||||
self.ap,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
|
||||
import { RefreshCw, Trash2, Clock3, Puzzle } from 'lucide-react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
@@ -29,6 +29,7 @@ import PluginProcessorTrace, {
|
||||
ProcessorPayload,
|
||||
} from './components/PluginProcessorTrace';
|
||||
import ProcessorRunList from './components/ProcessorRunList';
|
||||
import { processorRunDuration, formatRunDuration } from './components/processor-run-timing';
|
||||
import PluginProcessorSettings from './components/PluginProcessorSettings';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
|
||||
@@ -52,10 +53,14 @@ export default function PluginProcessorDetailContent({
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
searchParams.get('tab') === 'logs' ? 'logs' : 'config',
|
||||
);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const setActiveTab = useCallback((tab: string) => {
|
||||
setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set('tab', tab);
|
||||
return next;
|
||||
}, { preventScrollReset: true });
|
||||
}, [setSearchParams]);
|
||||
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
|
||||
const toolLabels = Object.fromEntries(
|
||||
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
|
||||
@@ -135,7 +140,7 @@ export default function PluginProcessorDetailContent({
|
||||
: []),
|
||||
{
|
||||
id: 'logs',
|
||||
target: '[data-guide="event-processor-form-tab-logs"]',
|
||||
target: '[data-guide="event-processor-form-monitoring"]',
|
||||
title: t('guidedTour.pluginProcessor.logs.title'),
|
||||
description: t('guidedTour.pluginProcessor.logs.description'),
|
||||
onEnter: () => setActiveTab('logs'),
|
||||
@@ -152,7 +157,7 @@ export default function PluginProcessorDetailContent({
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[t, available, hasParameters, canOperate],
|
||||
[t, available, hasParameters, canOperate, setActiveTab],
|
||||
);
|
||||
|
||||
const applyInstalledProcessor = useCallback((component: RunnerDescriptor) => {
|
||||
@@ -170,7 +175,7 @@ export default function PluginProcessorDetailContent({
|
||||
);
|
||||
setActiveTab('config');
|
||||
validate.current = null;
|
||||
}, []);
|
||||
}, [setActiveTab]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -356,14 +361,7 @@ export default function PluginProcessorDetailContent({
|
||||
}
|
||||
|
||||
const logsContent = (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<form
|
||||
id="event-processor-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
|
||||
{failed && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
@@ -377,16 +375,19 @@ export default function PluginProcessorDetailContent({
|
||||
<span className="text-muted-foreground">({runs.length})</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t('agents.eventProcessor.refresh')}
|
||||
onClick={() => void refreshLatestRun()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{t('agents.eventProcessor.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
{runs.length > 0 && (
|
||||
<ProcessorRunList
|
||||
className="max-h-64 overflow-y-auto rounded-lg border lg:max-h-none lg:h-full"
|
||||
runs={runs}
|
||||
selectedId={selected?.run_id}
|
||||
onSelect={(run) => void openRun(run)}
|
||||
@@ -405,7 +406,7 @@ export default function PluginProcessorDetailContent({
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 pr-3">
|
||||
<div className="space-y-4 pr-2">
|
||||
{!selected ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -421,25 +422,31 @@ export default function PluginProcessorDetailContent({
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-b pb-2">
|
||||
<p className="text-sm font-medium">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(selected.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{processorRunDuration(selected) !== null && (
|
||||
<span className="flex items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
|
||||
<Clock3 className="size-3.5" />
|
||||
{formatRunDuration(processorRunDuration(selected)!)}
|
||||
</span>
|
||||
)}
|
||||
<Badge status={selected.status}>
|
||||
{t(`agents.eventProcessor.status_${selected.status}`, { defaultValue: selected.status })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
selected.status === 'failed' ? 'destructive' : 'outline'
|
||||
}
|
||||
>
|
||||
{t(`agents.eventProcessor.status_${selected.status}`, {
|
||||
defaultValue: selected.status,
|
||||
})}
|
||||
</Badge>
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<h3 className="text-sm font-medium">{t('agents.eventProcessor.input')}</h3>
|
||||
<ProcessorPayload
|
||||
title={t('agents.eventProcessor.input')}
|
||||
title={t('agents.monitoring.eventData')}
|
||||
value={selected.metadata.input_event}
|
||||
/>
|
||||
{selected.metadata.delivery != null && (
|
||||
@@ -448,7 +455,14 @@ export default function PluginProcessorDetailContent({
|
||||
value={selected.metadata.delivery}
|
||||
/>
|
||||
)}
|
||||
<PluginProcessorTrace events={events} toolLabels={toolLabels} />
|
||||
</div>
|
||||
<section className="space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
{t('agents.eventProcessor.type')} · {t('agents.eventProcessor.logsTab')}
|
||||
</h2>
|
||||
<PluginProcessorTrace events={events} toolLabels={toolLabels} />
|
||||
</section>
|
||||
{selected.status === 'failed' && selected.status_reason && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="break-words">
|
||||
@@ -469,6 +483,7 @@ export default function PluginProcessorDetailContent({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -477,6 +492,10 @@ export default function PluginProcessorDetailContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="event-processor-form" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}} />
|
||||
<GuidedTour
|
||||
enabled={canManage}
|
||||
storageKey="langbot_plugin_processor_setup_guide_v1"
|
||||
@@ -485,6 +504,12 @@ export default function PluginProcessorDetailContent({
|
||||
/>
|
||||
<ProcessorDetailWorkbench
|
||||
title={`${agent.emoji || '🧩'} ${agent.name}`}
|
||||
titleBadge={
|
||||
<Badge variant="outline" className="gap-1.5 border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||
<Puzzle className="size-3.5" />
|
||||
{t('agents.eventProcessor.type')}
|
||||
</Badge>
|
||||
}
|
||||
titleAction={
|
||||
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
|
||||
}
|
||||
@@ -529,15 +554,13 @@ export default function PluginProcessorDetailContent({
|
||||
) : undefined
|
||||
}
|
||||
configTitle={t('agents.eventProcessor.type')}
|
||||
configTabs={{
|
||||
value: activeTab,
|
||||
onValueChange: setActiveTab,
|
||||
items: [
|
||||
{
|
||||
value: 'config',
|
||||
label: t('agents.eventProcessor.configTab'),
|
||||
icon: <Settings2 className="size-4" />,
|
||||
content: (
|
||||
configIcon={<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />}
|
||||
monitoring={{
|
||||
label: t('pipelines.monitoring.title'),
|
||||
workbenchLabel: t('pipelines.monitoring.workbench'),
|
||||
content: logsContent,
|
||||
}}
|
||||
configContent={
|
||||
<div className="h-full overflow-y-auto">
|
||||
{!component ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -563,16 +586,7 @@ export default function PluginProcessorDetailContent({
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'logs',
|
||||
label: t('agents.eventProcessor.logsTab'),
|
||||
icon: <ScrollText className="size-4" />,
|
||||
content: logsContent,
|
||||
},
|
||||
],
|
||||
}}
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugDescription={t('agents.eventProcessor.debugNotice')}
|
||||
debugContent={
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ChevronRight, ScrollText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -47,23 +46,25 @@ export default function PluginProcessorTrace({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
{events.map((event, index) =>
|
||||
event.type === 'processor.log' ? (
|
||||
<Alert
|
||||
<div
|
||||
key={event.sequence ?? index}
|
||||
variant={event.data.level === 'error' ? 'destructive' : 'default'}
|
||||
className="min-w-0 overflow-hidden rounded-lg border bg-background"
|
||||
>
|
||||
<AlertDescription className="flex min-w-0 items-start gap-2">
|
||||
<Badge status={String(event.data.level)}>{String(event.data.level)}</Badge>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-3">
|
||||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm font-medium">{t('agents.eventProcessor.logsTab')}</span>
|
||||
<Badge status={String(event.data.level)}>{String(event.data.level)}</Badge>
|
||||
</div>
|
||||
<p className={`min-w-0 whitespace-pre-wrap break-words p-4 text-sm leading-7 [overflow-wrap:anywhere] ${event.data.level === 'error' ? 'text-destructive' : ''}`}>
|
||||
{String(event.data.text)}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div key={event.sequence ?? index} className="rounded-lg border bg-background p-3">
|
||||
<ProcessorPayload
|
||||
key={event.sequence ?? index}
|
||||
title={
|
||||
<>
|
||||
{t(
|
||||
@@ -79,6 +80,7 @@ export default function PluginProcessorTrace({
|
||||
}
|
||||
value={event.data}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,18 +15,20 @@ export default function ProcessorRunList({
|
||||
selectedId,
|
||||
onSelect,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
runs: ProcessorRun[];
|
||||
selectedId?: string;
|
||||
onSelect: (run: ProcessorRun) => void;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('agents.eventProcessor.runs')}
|
||||
className="max-h-56 shrink-0 overflow-y-auto rounded-md border"
|
||||
className={className ?? 'max-h-56 shrink-0 overflow-y-auto rounded-md border'}
|
||||
>
|
||||
{runs.map((run) => {
|
||||
const duration = processorRunDuration(run);
|
||||
@@ -41,7 +43,7 @@ export default function ProcessorRunList({
|
||||
onClick={() => onSelect(run)}
|
||||
aria-label={`${label} ${event} ${new Date(run.created_at * 1000).toLocaleString()}`}
|
||||
title={event}
|
||||
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b px-3 py-2 text-left text-sm font-normal last:border-b-0 aria-pressed:bg-accent"
|
||||
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b border-l-2 border-l-transparent p-4 text-left text-sm font-normal last:border-b-0 aria-pressed:border-l-primary aria-pressed:bg-primary/5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block truncate font-medium">{label}</span>
|
||||
|
||||
@@ -655,6 +655,12 @@ function NavItems({
|
||||
if (item.extensionType === 'skill') {
|
||||
return `/home/skills?id=${encodeURIComponent(item.id)}`;
|
||||
}
|
||||
const processorTab = searchParams.get('tab');
|
||||
if (hasDetailPages && (routePrefix === '/home/agents' || routePrefix === '/home/pipelines') &&
|
||||
(pathname === '/home/agents' || pathname === '/home/pipelines') &&
|
||||
(processorTab === 'logs' || processorTab === 'config')) {
|
||||
return `${routePrefix}?id=${encodeURIComponent(item.id)}&tab=${processorTab}`;
|
||||
}
|
||||
return hasDetailPages
|
||||
? `${routePrefix}?id=${encodeURIComponent(item.id)}`
|
||||
: routePrefix;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { BarChart3, Bug, Info, Settings } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -56,6 +57,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
debugDisconnectedLabel?: string;
|
||||
unsavedLabel?: string;
|
||||
monitoring?: ProcessorMonitoringView;
|
||||
view?: 'workbench' | 'monitoring';
|
||||
onViewChange?: (view: 'workbench' | 'monitoring') => void;
|
||||
}
|
||||
|
||||
@@ -83,11 +85,11 @@ export default function ProcessorDetailWorkbench({
|
||||
debugDisconnectedLabel,
|
||||
unsavedLabel,
|
||||
monitoring,
|
||||
view,
|
||||
onViewChange,
|
||||
}: ProcessorDetailWorkbenchProps) {
|
||||
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
|
||||
'workbench',
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeView = searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
|
||||
const hasDebug = Boolean(debugTitle && debugContent);
|
||||
|
||||
const configPanel = (
|
||||
@@ -143,10 +145,14 @@ export default function ProcessorDetailWorkbench({
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeView}
|
||||
value={view ?? activeView}
|
||||
onValueChange={(value) => {
|
||||
const view = value as 'workbench' | 'monitoring';
|
||||
setActiveView(view);
|
||||
setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set('tab', view === 'monitoring' ? 'logs' : 'config');
|
||||
return next;
|
||||
}, { preventScrollReset: true });
|
||||
onViewChange?.(view);
|
||||
}}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
|
||||
@@ -226,7 +232,7 @@ export default function ProcessorDetailWorkbench({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canSave && activeView === 'workbench' && (
|
||||
{canSave && (view ?? activeView) === 'workbench' && (
|
||||
<Button
|
||||
type="submit"
|
||||
form={saveFormId}
|
||||
@@ -236,7 +242,7 @@ export default function ProcessorDetailWorkbench({
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{activeView === 'workbench' && headerActions}
|
||||
{(view ?? activeView) === 'workbench' && headerActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function TokenMonitoring({
|
||||
}));
|
||||
}, [stats]);
|
||||
|
||||
if (loading) {
|
||||
if (loading && !stats) {
|
||||
return <TabState loading rows={6} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ function TrafficChartContent({ traffic, loading }: TrafficChartProps) {
|
||||
[traffic],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
if (loading && !traffic) {
|
||||
return (
|
||||
<div>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
@@ -162,6 +162,7 @@ function TrafficChartContent({ traffic, loading }: TrafficChartProps) {
|
||||
itemStyle={{ padding: '4px 0' }}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="messages"
|
||||
name={t('monitoring.trafficChart.messages')}
|
||||
@@ -173,6 +174,7 @@ function TrafficChartContent({ traffic, loading }: TrafficChartProps) {
|
||||
activeDot={{ r: 6, strokeWidth: 2 }}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="llmCalls"
|
||||
name={t('monitoring.trafficChart.llmCalls')}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useExecutions(params: {
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
|
||||
const requestIdRef = useRef(0);
|
||||
const scope = JSON.stringify([workspaceUuid, params]);
|
||||
const scope = JSON.stringify([workspaceUuid, { ...params, refreshKey: undefined }]);
|
||||
const [requestScope, setRequestScope] = useState<string | null>(null);
|
||||
|
||||
// Memoize filter parameters to prevent unnecessary re-renders
|
||||
@@ -58,8 +58,6 @@ export function useExecutions(params: {
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -88,8 +86,11 @@ export function useExecutions(params: {
|
||||
if (!isCurrent()) return;
|
||||
|
||||
setResult(response);
|
||||
setRequestScope(scope);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setRequestScope(scope);
|
||||
setResult((current) => requestScope === scope ? current : null);
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch executions:', err);
|
||||
} finally {
|
||||
@@ -107,6 +108,7 @@ export function useExecutions(params: {
|
||||
params.limit,
|
||||
params.offset,
|
||||
scope,
|
||||
requestScope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -387,8 +385,11 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
setData(transformedData);
|
||||
setRequestScope(scope);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setRequestScope(scope);
|
||||
setData((current) => requestScope === scope ? current : null);
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch monitoring data:', err);
|
||||
} finally {
|
||||
@@ -402,6 +403,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
requestScope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ function MonitoringPageContent() {
|
||||
} = useMonitoringFilters();
|
||||
const executionMode = filterState.mode ?? 'all';
|
||||
const executionStatus = filterState.statusGroup ?? 'all';
|
||||
const { data, loading, error, refetch } = useMonitoringData({
|
||||
const { data, loading: monitoringRefreshing, error, refetch } = useMonitoringData({
|
||||
...filterState,
|
||||
mode: executionMode,
|
||||
statusGroup: executionStatus,
|
||||
@@ -74,7 +74,7 @@ function MonitoringPageContent() {
|
||||
|
||||
const {
|
||||
result: executionResult,
|
||||
loading: executionLoading,
|
||||
loading: executionRefreshing,
|
||||
error: executionError,
|
||||
refetch: refetchExecutions,
|
||||
} = useExecutions({
|
||||
@@ -120,7 +120,7 @@ function MonitoringPageContent() {
|
||||
const {
|
||||
feedback: feedbackList,
|
||||
stats: feedbackStats,
|
||||
loading: feedbackLoading,
|
||||
loading: feedbackRefreshing,
|
||||
} = useFeedbackData({
|
||||
mode: executionMode,
|
||||
statusGroup: executionStatus,
|
||||
@@ -137,6 +137,10 @@ function MonitoringPageContent() {
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const loading = monitoringRefreshing && !data;
|
||||
const executionLoading = executionRefreshing && !executionResult;
|
||||
const feedbackLoading = feedbackRefreshing && !feedbackStats;
|
||||
const refreshing = monitoringRefreshing || executionRefreshing || feedbackRefreshing;
|
||||
const countdown = useRef(0);
|
||||
const intervalRef = useRef(0);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
@@ -157,8 +161,8 @@ function MonitoringPageContent() {
|
||||
});
|
||||
const refreshState = useRef({ handleRefresh, busy: false });
|
||||
useEffect(() => {
|
||||
refreshState.current = { handleRefresh, busy: loading || executionLoading || feedbackLoading };
|
||||
}, [handleRefresh, loading, executionLoading, feedbackLoading]);
|
||||
refreshState.current = { handleRefresh, busy: refreshing };
|
||||
}, [handleRefresh, refreshing]);
|
||||
useEffect(() => {
|
||||
intervalRef.current = refreshInterval;
|
||||
countdown.current = refreshInterval;
|
||||
@@ -228,10 +232,10 @@ function MonitoringPageContent() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading || executionLoading || feedbackLoading}
|
||||
disabled={refreshing}
|
||||
className="gap-2 rounded-none px-3"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', (loading || executionLoading || feedbackLoading) && 'animate-spin')} />
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
{t('monitoring.refreshData')}
|
||||
{refreshInterval > 0 && <span className="min-w-8 text-right text-xs tabular-nums text-muted-foreground">{remainingSeconds}s</span>}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { isCurrentPipelineConfig } from './pipeline-config-safety';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
@@ -51,10 +51,8 @@ export default function PipelineDetailContent({
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
|
||||
'workbench',
|
||||
);
|
||||
useEffect(() => setActiveView('workbench'), [id]);
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeView = searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
@@ -187,7 +185,6 @@ export default function PipelineDetailContent({
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
onViewChange={setActiveView}
|
||||
title={`${pipelineEmoji} ${pipelineName}`}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
|
||||
Reference in New Issue
Block a user