fix(acceptance): improve execution monitoring and processor navigation

This commit is contained in:
Hyu
2026-10-09 18:54:32 +08:00
parent c5c3ec0b2c
commit fea35e8b3f
15 changed files with 221 additions and 96 deletions
@@ -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
+7
View File
@@ -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,
+3 -2
View File
@@ -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,
]);
+11 -7
View File
@@ -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 ? (