diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 976dabffe..ceec4d2ec 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -177,6 +177,11 @@ logs and action results. These times are not internal plugin profiling data. ## Unified execution monitoring +Use `get_inflight_executions` for a bounded current-workspace snapshot of active +and recently started executions, including `progress_event`. Progress is a +reported stage, not an estimated completion percentage. The UI uses the shared +`GET /api/v1/monitoring/in-flight/stream` SSE feed instead of polling per viewer. + Use `get_monitoring_executions` for the execution list and its legacy `pipeline_ids` parameter to filter any processor kind (Agent, Pipeline or event processor); the summary uses the same processor scope. Use diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py index 1cb3d476d..e34a632c6 100644 --- a/src/langbot/pkg/agent/runner/run_ledger_store.py +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import sessionmaker from ...persistence.datetime_utils import as_naive_utc +from ...utils.inflight import inflight_hub from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime @@ -149,6 +150,7 @@ class RunLedgerStore: ) session.add(run) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def claim_next_run( @@ -206,6 +208,7 @@ class RunLedgerStore: run.last_claimed_at = as_naive_utc(now) run.updated_at = as_naive_utc(now) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run, include_claim_token=True) async def renew_claim( @@ -226,6 +229,7 @@ class RunLedgerStore: run.claim_lease_expires_at = as_naive_utc(now + datetime.timedelta(seconds=max(int(lease_seconds), 1))) run.updated_at = as_naive_utc(now) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def release_claim( @@ -254,6 +258,7 @@ class RunLedgerStore: if status in TERMINAL_STATUSES: run.finished_at = run.finished_at or as_naive_utc(now) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def release_expired_claims( @@ -332,6 +337,7 @@ class RunLedgerStore: ) session.add(row) await session.commit() + inflight_hub.notify_run(run_id, event_type) return self._event_to_dict(row) async def append_audit_event( @@ -366,6 +372,7 @@ class RunLedgerStore: ) session.add(row) await session.commit() + inflight_hub.notify_run(run_id, event_type) return self._event_to_dict(row) async def finalize_run( @@ -410,6 +417,7 @@ class RunLedgerStore: else: run.metadata_json = _json_dumps(metadata) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def validate_active_claim( @@ -443,6 +451,7 @@ class RunLedgerStore: run.updated_at = as_naive_utc(now) run.status_reason = status_reason or run.status_reason await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def get_run(self, run_id: str) -> dict[str, typing.Any] | None: diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index 9b00e356a..3f9dae245 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -59,6 +59,31 @@ class MonitoringRouterGroup(group.RouterGroup): return self.success(data=metrics) + @self.route('/in-flight/stream', methods=['GET'], permission=Permission.RESOURCE_VIEW) + async def stream_inflight(request_context: RequestContext): + from .....utils.inflight import inflight_hub + from ...service.tenant import require_workspace_uuid + import json + + workspace = require_workspace_uuid(request_context) + previous = [] + + async def snapshot(): + nonlocal previous + result = await self.ap.monitoring_service.get_inflight_snapshot(request_context, previous) + previous = [row for row in result['items'] if row['status_group'] in {'running', 'queued'}] + return result + + async def frames(): + async for frame in inflight_hub.watch(workspace, snapshot): + yield 'data: ' + json.dumps(frame, ensure_ascii=False) + '\n\n' + + response = quart.Response(frames(), content_type='text/event-stream') + response.headers['Cache-Control'] = 'no-cache, no-store' + response.headers['X-Accel-Buffering'] = 'no' + response.timeout = None + return response + @self.route('/executions', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_executions(request_context: RequestContext) -> str: """Unified execution list across agent runs and pipeline queries.""" diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index ad5b8858a..d4ec46591 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -127,8 +127,13 @@ def _workspace_transaction(method): tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) if callable(tenant_uow): async with tenant_uow(workspace_uuid): - return await method(self, context, *args, **kwargs) - return await method(self, context, *args, **kwargs) + result = await method(self, context, *args, **kwargs) + else: + result = await method(self, context, *args, **kwargs) + if method.__name__ in {'record_message', 'update_message_status'}: + from ....utils.inflight import inflight_hub + inflight_hub.notify(workspace_uuid) + return result return wrapped @@ -1533,6 +1538,7 @@ class MonitoringService(ExecutionDetailsMixin): end_time: datetime.datetime | None = None, limit: int = 50, offset: int = 0, + include_summary: bool = True, ) -> dict: """List executions from both object types (agent runs and pipeline queries).""" workspace_uuid = require_workspace_uuid(context) @@ -1633,6 +1639,8 @@ class MonitoringService(ExecutionDetailsMixin): by_key[(kind, item['id'])] = item page = [by_key[(ref.source, ref.id)] for ref in refs if (ref.source, ref.id) in by_key] await self.enrich_execution_rows(workspace_uuid, page) + if not include_summary: + return {'items': page, 'total': total, 'has_more': total > offset + len(page)} return { 'items': page, 'total': total, @@ -1661,6 +1669,54 @@ class MonitoringService(ExecutionDetailsMixin): }, } + async def get_inflight_snapshot(self, context: TenantContext, previous: list[dict] | None = None) -> dict: + """Bounded Workspace snapshot without dashboard aggregates or token payloads.""" + from ....utils.inflight import inflight_hub + + workspace = require_workspace_uuid(context) + active = await self.get_executions(context, statuses=['running', 'queued'], limit=100, include_summary=False) + recent = await self.get_executions( + context, start_time=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=30), + limit=100, include_summary=False, + ) + items = {(r['source'], r['id']): r for r in recent['items'] + active['items'] if r['source'] != 'event'} + # Keep the actual terminal state of older runs that just left the active set. + for kind, model, column, scope in [ + ('agent', persistence_agent_run.AgentRun, persistence_agent_run.AgentRun.run_id, persistence_agent_run.AgentRun.workspace_id), + ('pipeline', persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage.id, persistence_monitoring.MonitoringMessage.workspace_uuid), + ]: + missing = [r['id'] for r in (previous or [])[:100] if r['source'] == kind and (kind, r['id']) not in items] + if missing: + records = await self._execution_rows(workspace, sqlalchemy.select(model).where(scope == workspace, column.in_(missing))) + names = await self._agent_names(workspace, records) if kind == 'agent' else {} + for record in records: + row = self._serialize_agent_execution(record, names) if kind == 'agent' else self._serialize_pipeline_execution(record) + items[(kind, row['id'])] = row + rows = list(items.values()) + Run = persistence_agent_run.AgentRun + Message = persistence_monitoring.MonitoringMessage + agent_ids = [r['id'] for r in rows if r['source'] == 'agent'] + pipeline_ids = [r['id'] for r in rows if r['source'] == 'pipeline'] + linked = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Run.run_id, Message.id.label('message_id')).outerjoin( + Message, sqlalchemy.and_(Message.workspace_uuid == workspace, Message.run_id == Run.run_id) + ).where(Run.workspace_id == workspace, sqlalchemy.or_(Run.run_id.in_(agent_ids), Message.id.in_(pipeline_ids))) + ) + identities = {r.run_id: ('pipeline', r.message_id) if r.message_id in pipeline_ids else ('agent', r.run_id) for r in linked.all()} + inflight_hub.track(workspace, identities) + Event = persistence_agent_run.AgentRunEvent + latest = sqlalchemy.select(Event.run_id, sqlalchemy.func.max(Event.sequence).label('seq')).where( + Event.run_id.in_(identities) + ).group_by(Event.run_id).subquery() + phases = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Event.run_id, Event.type).join(latest, sqlalchemy.and_(Event.run_id == latest.c.run_id, Event.sequence == latest.c.seq)) + ) + for run_id, event_type in phases.all(): + row = items.get(identities[run_id]) + if row is not None: + row['progress_event'] = event_type + return {'items': rows, 'active_total': active['total'], 'truncated': active['has_more'] or recent['has_more']} + async def get_token_statistics( self, context: TenantContext, diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 6c2367a37..030c89f4e 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -207,6 +207,10 @@ class LangBotMCPServer: return _dump(await ap.monitoring_service.get_message_details(context, identifier)) return _dump(await ap.monitoring_service.get_session_analysis(context, identifier)) + @mcp.tool(description='Read a bounded snapshot of active and recently started Workspace executions, including the latest progress event. No percentage is estimated.') + async def get_inflight_executions() -> str: + return _dump(await ap.monitoring_service.get_inflight_snapshot(_authorized(Permission.RESOURCE_VIEW))) + @mcp.tool( description='List Workspace executions, including events that did not start a processor. The legacy pipeline_ids filter accepts Agent, Pipeline and event processor IDs. Returns bounded rows and summary metrics.' ) diff --git a/src/langbot/pkg/utils/inflight.py b/src/langbot/pkg/utils/inflight.py new file mode 100644 index 000000000..6a0f1a0e0 --- /dev/null +++ b/src/langbot/pkg/utils/inflight.py @@ -0,0 +1,94 @@ +"""Bounded, process-local fan-out. One snapshot producer per watched Workspace. + +Writes wake the producer; quiet workspaces reconcile every 15 seconds to cover +external workers. A shared broker can replace notify without changing clients. +No payloads or subscriptions survive the final viewer disconnecting. +""" + +import asyncio +import contextlib +import time + + +class InflightHub: + def __init__(self): + self.channels = {} + self.run_workspaces = {} + + def notify(self, workspace): + channel = self.channels.get(workspace) + if channel: + channel['dirty'].set() + + def notify_run(self, run_id, event_type): + workspace = self.run_workspaces.get(run_id) + channel = self.channels.get(workspace) + if channel and channel['phases'].get(run_id) != event_type: + channel['phases'][run_id] = event_type + channel['dirty'].set() + + def track(self, workspace, run_ids): + self.run_workspaces = {key: value for key, value in self.run_workspaces.items() if value != workspace} + if workspace not in self.channels: + return + self.run_workspaces.update({run_id: workspace for run_id in run_ids}) + if workspace in self.channels: + phases = self.channels[workspace]['phases'] + self.channels[workspace]['phases'] = {key: value for key, value in phases.items() if key in run_ids} + + async def watch(self, workspace, loader): + if sum(len(c['queues']) for c in self.channels.values()) >= 1024: + raise RuntimeError('Too many monitoring connections') + channel = self.channels.get(workspace) + if channel is None: + channel = dict(queues=set(), dirty=asyncio.Event(), phases={}, snapshot=None) + self.channels[workspace] = channel + channel['task'] = asyncio.create_task(self._produce(channel, loader)) + if len(channel['queues']) >= 64: + raise RuntimeError('Too many Workspace monitoring connections') + queue = asyncio.Queue(maxsize=1) + channel['queues'].add(queue) + if channel['snapshot'] is not None: + queue.put_nowait(channel['snapshot']) + try: + # Reconnect periodically so HTTP authentication/authorization is renewed. + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + yield await asyncio.wait_for(queue.get(), timeout=min(10, deadline - time.monotonic())) + except asyncio.TimeoutError: + yield {'kind': 'heartbeat'} + finally: + channel['queues'].discard(queue) + if not channel['queues']: + self.channels.pop(workspace, None) + self.track(workspace, []) + channel['task'].cancel() + with contextlib.suppress(asyncio.CancelledError): + await channel['task'] + + async def _produce(self, channel, loader): + previous = None + while True: + channel['dirty'].clear() + try: + snapshot = await loader() + frame = {'kind': 'snapshot', 'data': snapshot} + except Exception: + frame = {'kind': 'unavailable'} + # Heartbeats are sent separately; unchanged snapshots generate no traffic. + if frame != previous: + channel['snapshot'] = frame + for queue in tuple(channel['queues']): + if queue.full(): + queue.get_nowait() + queue.put_nowait(frame) + previous = frame + await asyncio.sleep(0.75) + try: + await asyncio.wait_for(channel['dirty'].wait(), timeout=15) + except asyncio.TimeoutError: + pass + + +inflight_hub = InflightHub() diff --git a/tests/unit_tests/api/service/test_monitoring_executions.py b/tests/unit_tests/api/service/test_monitoring_executions.py index ce3d79f34..eeb859c81 100644 --- a/tests/unit_tests/api/service/test_monitoring_executions.py +++ b/tests/unit_tests/api/service/test_monitoring_executions.py @@ -736,3 +736,60 @@ async def test_pipeline_debug_is_consistent_across_rows_rollups_and_telemetry(en assert real_calls == 1 and real_rows[0]['id'] == 'llm-2' traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug', pipeline_ids=['pipeline-1']) assert sum(p['llm_calls'] for p in traffic['points']) == 1 + + +@pytest.mark.asyncio +async def test_inflight_snapshot_is_scoped_and_keeps_terminal_transition(engine, service): + import sqlalchemy as sa + from unittest.mock import AsyncMock + + service._get_execution_summary = AsyncMock(side_effect=AssertionError('No dashboard aggregates')) + service._get_token_coverage = AsyncMock(side_effect=AssertionError('No dashboard aggregates')) + async with AsyncSession(engine) as session: + for workspace, run_id in [(WORKSPACE, 'flight-visible'), ('other-workspace', 'flight-secret')]: + session.add(persistence_agent_run.AgentRun( + run_id=run_id, workspace_id=workspace, runner_id='runner', status='running', created_at=_dt(1), + )) + session.add(persistence_agent_run.AgentRunEvent(run_id='flight-visible', sequence=1, type='tool.started')) + await session.commit() + snapshot = await service.get_inflight_snapshot(WORKSPACE) + rows = {r['id']: r for r in snapshot['items']} + assert 'flight-secret' not in rows + assert rows['flight-visible']['progress_event'] == 'tool.started' + async with AsyncSession(engine) as session: + await session.execute(sa.update(persistence_agent_run.AgentRun).where( + persistence_agent_run.AgentRun.run_id == 'flight-visible' + ).values(status='completed')) + await session.commit() + next_snapshot = await service.get_inflight_snapshot(WORKSPACE, [rows['flight-visible']]) + assert next(r for r in next_snapshot['items'] if r['id'] == 'flight-visible')['status_group'] == 'completed' + + +@pytest.mark.asyncio +async def test_inflight_viewers_share_work_and_cleanup(): + import asyncio + from unittest.mock import AsyncMock + from langbot.pkg.utils.inflight import InflightHub + + hub = InflightHub() + loader = AsyncMock(return_value={'items': []}) + first = hub.watch('one', loader) + second = hub.watch('one', loader) + assert (await anext(first))['kind'] == 'snapshot' + assert (await anext(second))['kind'] == 'snapshot' + assert loader.await_count == 1 + other_loader = AsyncMock(return_value={'items': ['private']}) + other = hub.watch('two', other_loader) + assert (await anext(other))['data']['items'] == ['private'] + hub.track('one', ['run']) + hub.notify_run('run', 'tool.started') + loader.return_value = {'items': ['updated']} + frame = await asyncio.wait_for(anext(first), 2) + assert frame['data']['items'] == ['updated'] + assert (await anext(second)) == frame + await first.aclose() + assert 'one' in hub.channels + await second.aclose() + assert 'one' not in hub.channels and 'run' not in hub.run_workspaces + await other.aclose() + assert not hub.channels diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index bcce0e9da..de9e9bdf1 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -1,6 +1,6 @@ import EntityLoadState from '@/components/EntityLoadState'; import { useState, useEffect, useRef, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; @@ -22,7 +22,14 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses import { httpClient } from '@/app/infra/http/HttpClient'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; -import { Settings, FileText, Users, RefreshCw, Trash2, Loader2 } from 'lucide-react'; +import { + Settings, + FileText, + Users, + RefreshCw, + Trash2, + Loader2, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { showBotError } from './bot-error'; @@ -56,7 +63,23 @@ export default function BotDetailContent({ id }: { id: string }) { return () => setDetailEntityName(null); }, [id, isCreateMode, bots, setDetailEntityName, t]); - const [activeTab, setActiveTab] = useState('config'); + const [searchParams, setSearchParams] = useSearchParams(); + const requestedTab = searchParams.get('tab'); + const activeTab = + !isCreateMode && + canViewMonitoring && + ['logs', 'sessions'].includes(requestedTab || '') + ? requestedTab! + : 'config'; + const setActiveTab = (tab: string) => + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + next.set('tab', tab); + return next; + }, + { replace: true }, + ); const [adapterLabel, setAdapterLabel] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [basicInfoOpen, setBasicInfoOpen] = useState(false); @@ -101,7 +124,9 @@ export default function BotDetailContent({ id }: { id: string }) { setBot((current) => current ? { ...current, enable: checked } : current, ); - toast.success(t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed')); + toast.success( + t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed'), + ); refreshBots(); } catch (error) { setBotEnabled(prev); @@ -217,17 +242,34 @@ export default function BotDetailContent({ id }: { id: string }) { {enableLoaded && (
- {isTogglingEnable - ? - {t(formDirty && !botEnabled ? 'bots.saveAndEnable' : 'common.save')} + {t( + formDirty && !botEnabled + ? 'bots.saveAndEnable' + : 'common.save', + )} + ) : ( +
+ + + +
+ )} + {!collapsed && ( + <> + {!connected && ( +

+ {t('inflight.reconnecting')} +

+ )} +
+ {rows.length === 0 && connected && ( +
+ +

{t('inflight.empty')}

+
+ )} + {ordered.slice(0, 20).map((row) => { + const phase = stage(row); + const stageKey = `inflight.${phase}`; + const label = t(stageKey, { + defaultValue: executionStatusLabel(row, t), + }); + const elapsed = Math.max( + 0, + Math.floor( + ((row.finished_at_ms ?? row.settledAt ?? now) - + (row.started_at_ms ?? row.created_at_ms ?? now)) / + 1000, + ), + ); + const eventKey = `bots.eventNames.${(row.event_type || row.title).replaceAll('.', '_')}`; + const processorUrl = + row.target_id && + [ + 'pipeline', + 'agent', + 'processor', + 'event_processor', + ].includes(row.target_kind) + ? `/home/${row.target_kind === 'pipeline' ? 'pipelines' : 'agents'}?id=${encodeURIComponent(row.target_id)}&tab=logs` + : null; + const leaving = + !!row.settledAt && now - row.settledAt >= 7000; + return ( +
+
+ + + + + + {elapsed}s + +
+ +
+
+
+
+ ); + })} +
+ {(truncated || rows.length > 20) && ( +

+ {t('inflight.limited')} +

+ )} + + )} +
+ + setSelected(null)} + /> + + ); +} + +export default function InFlightMonitor() { + const workspace = useCurrentWorkspace(); + if (!workspace?.permissions.includes('resource.view')) return null; + return ; +} diff --git a/web/src/app/home/components/inflight/inflight.module.css b/web/src/app/home/components/inflight/inflight.module.css new file mode 100644 index 000000000..8698f57b0 --- /dev/null +++ b/web/src/app/home/components/inflight/inflight.module.css @@ -0,0 +1,125 @@ +.active { + width: 40%; + animation: travel 1.6s ease-in-out infinite; +} +@keyframes travel { + from { + transform: translateX(-100%); + } + to { + transform: translateX(350%); + } +} +@media (prefers-reduced-motion: reduce) { + .active { + animation: none; + width: 40%; + } +} +.panel { + contain: layout; + overflow-x: hidden; + transition: + width 180ms ease-out, + transform 180ms ease-out; +} +.task { + animation: arrive 180ms ease-out both; +} +.leaving { + animation: depart 180ms ease-in both; + pointer-events: none; +} +@keyframes arrive { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +@keyframes depart { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(8px); + } +} +@media (prefers-reduced-motion: reduce) { + .task, + .leaving { + animation: none; + } +} + +.panel[data-dragging='true'] { + transition: none; +} +.dockContent { + animation: dockReveal 160ms ease-out both; +} +@keyframes dockReveal { + from { + opacity: 0.35; + transform: translateX(5px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +@media (prefers-reduced-motion: reduce) { + .panel { + transition: none; + } + .dockContent { + animation: none; + } +} + +.connection { + position: relative; + height: 2px; + background: var(--border); +} +.connection::after { + content: ''; + position: absolute; + right: 1px; + top: -2px; + width: 6px; + height: 6px; + border-top: 1.5px solid var(--muted-foreground); + border-right: 1.5px solid var(--muted-foreground); + transform: rotate(45deg); +} +.flowing { + overflow: hidden; + background: color-mix(in srgb, var(--primary) 20%, transparent); +} +.flowing i { + display: block; + width: 8px; + height: 2px; + background: var(--primary); + animation: nodeFlow 1.4s ease-in-out infinite; +} +@keyframes nodeFlow { + from { + transform: translateX(-8px); + } + to { + transform: translateX(24px); + } +} +@media (prefers-reduced-motion: reduce) { + .flowing i { + animation: none; + transform: translateX(8px); + } +} diff --git a/web/src/app/home/components/inflight/useEdgeDock.ts b/web/src/app/home/components/inflight/useEdgeDock.ts new file mode 100644 index 000000000..4a7e4ddb0 --- /dev/null +++ b/web/src/app/home/components/inflight/useEdgeDock.ts @@ -0,0 +1,128 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type PointerEvent, + type KeyboardEvent, +} from 'react'; + +const KEY = 'langbot.inflight.dock'; +function saved() { + try { + return JSON.parse(localStorage.getItem(KEY) || '{}'); + } catch { + return {}; + } +} + +export function useEdgeDock(visible: boolean) { + const [preference] = useState(saved); + const [collapsed, setCollapsed] = useState(preference?.collapsed === true); + const panel = useRef(null); + const position = useRef( + Number.isFinite(preference?.position) + ? Math.min(1, Math.max(0, preference.position)) + : 0.55, + ); + const drag = useRef<{ + id: number; + y: number; + top: number; + moved: boolean; + } | null>(null); + const suppressClick = useRef(false); + // Anchor the top edge to the viewport, never to the changing content height. + const range = () => Math.max(0, window.innerHeight - 48 - 24); + const place = (top = 12 + position.current * range()) => { + const bounded = Math.max(12, Math.min(top, 12 + range())); + if (panel.current) { + panel.current.style.transform = `translateY(${bounded}px)`; + panel.current.style.maxHeight = `${Math.max(48, window.innerHeight - bounded - 12)}px`; + } + return bounded; + }; + const persist = () => { + try { + localStorage.setItem( + KEY, + JSON.stringify({ collapsed, position: position.current }), + ); + } catch { + /* Optional preference. */ + } + }; + useEffect(() => { + persist(); + }, [collapsed]); + useLayoutEffect(() => { + if (!visible || !panel.current) return; + const update = () => { + place(); + }; + window.addEventListener('resize', update); + update(); + return () => { + window.removeEventListener('resize', update); + }; + }, [visible, collapsed]); + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + if (panel.current) panel.current.dataset.dragging = 'true'; + suppressClick.current = false; + drag.current = { + id: event.pointerId, + y: event.clientY, + top: panel.current?.getBoundingClientRect().top || 12, + moved: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const onPointerMove = (event: PointerEvent) => { + const current = drag.current; + if (!current || current.id !== event.pointerId) return; + const delta = event.clientY - current.y; + if (Math.abs(delta) > 5) current.moved = true; + if (current.moved) { + const top = place(current.top + delta); + position.current = range() ? (top - 12) / range() : 0; + } + }; + const finish = (event: PointerEvent) => { + if (!drag.current || drag.current.id !== event.pointerId) return; + suppressClick.current = drag.current.moved; + drag.current = null; + if (panel.current) delete panel.current.dataset.dragging; + if (event.currentTarget.hasPointerCapture(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId); + persist(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const top = + event.key === 'Home' + ? 12 + : event.key === 'End' + ? 12 + range() + : (panel.current?.getBoundingClientRect().top || 12) + + (event.key === 'ArrowUp' ? -24 : 24); + const bounded = place(top); + position.current = range() ? (bounded - 12) / range() : 0; + persist(); + }; + return { + panel, + collapsed, + setCollapsed, + suppressClick, + handle: { + onPointerDown, + onPointerMove, + onPointerUp: finish, + onPointerCancel: finish, + onLostPointerCapture: finish, + onKeyDown, + }, + }; +} diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index 237f56761..056b715f9 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -1,5 +1,6 @@ import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar'; import SurveyWidget from '@/app/home/components/survey/SurveyWidget'; +import InFlightMonitor from '@/app/home/components/inflight/InFlightMonitor'; import WorkspaceAssistant from '@/app/home/components/WorkspaceAssistant'; import React, { useState, @@ -280,6 +281,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) { + ); diff --git a/web/src/app/infra/entities/api/monitoring-executions.ts b/web/src/app/infra/entities/api/monitoring-executions.ts index 2e708b80a..04d042dca 100644 --- a/web/src/app/infra/entities/api/monitoring-executions.ts +++ b/web/src/app/infra/entities/api/monitoring-executions.ts @@ -163,3 +163,12 @@ export interface ExecutionDetail { export type AgentExecutionDetail = ExecutionDetail & { source: 'agent' }; export type PipelineExecutionDetail = ExecutionDetail & { source: 'pipeline' }; + +export interface InflightSnapshot { + items: (ExecutionRow & { progress_event?: string })[]; + active_total: number; + truncated: boolean; +} +export type InflightFrame = + | { kind: 'snapshot'; data: InflightSnapshot } + | { kind: 'heartbeat' | 'unavailable' }; diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 4202daac1..0581fdf0d 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -6,6 +6,7 @@ import type { } from '@/app/infra/entities/api/pipeline-migration'; import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug'; import type { + InflightFrame, ExecutionDetail, ExecutionListResult, ExecutionModeFilter, @@ -2203,6 +2204,33 @@ export class BackendClient extends BaseHttpClient { return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`); } + public async streamInflight(onFrame: (frame: InflightFrame) => void, signal: AbortSignal): Promise { + const response = await this.instance.get>( + '/api/v1/monitoring/in-flight/stream', + { adapter: 'fetch', responseType: 'stream', timeout: 0, signal, headers: { Accept: 'text/event-stream' } }, + ); + const reader = response.data.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let end: number; + while ((end = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + if (frame.startsWith('data: ')) onFrame(JSON.parse(frame.slice(6)) as InflightFrame); + } + if (buffer.length > 2_000_000) throw new Error('Monitoring frame exceeds limit'); + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + } + public getExecutions(params: { botId?: string[]; pipelineId?: string[]; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 1c8414f13..c3f06a22a 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/en-US'; const enUS = { + inflight: { + autoCollapse: "Auto-collapse", + empty: "No events are being processed", + move: "Drag vertically; use arrow keys to move", + botLogs: "Robot logs", + processorLogs: "Processor logs", + + "title": "In flight", + "expand": "Expand", + "collapse": "Collapse", + "reconnecting": "Reconnecting; showing the last received state.", + "details": "View details", + "debug": "Debug event", + "limited": "Showing a limited set. Open the dashboard for all executions.", + "queued": "Queued", + "running": "Processing", + "waiting": "Waiting for input", + "tool": "Calling a tool", + "thinking": "Thinking", + "replying": "Preparing reply", + "generating": "Generating reply", + "completed": "Completed", + "failed": "Failed", + "cancelled": "Cancelled", + "ignored": "Ignored" + }, assistant: { movePosition: 'Move assistant horizontally', resizeHeight: 'Resize assistant height', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 0b9eafa91..7f08df1cd 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/es-ES'; const esES = { + inflight: { + autoCollapse: "Plegar auto.", + empty: "No hay eventos en proceso", + move: "Arrastra verticalmente o usa las flechas", + botLogs: "Registros del bot", + processorLogs: "Registros del procesador", + + "title": "En curso", + "expand": "Expandir", + "collapse": "Contraer", + "reconnecting": "Reconectando; se muestra el último estado recibido.", + "details": "Ver detalles", + "debug": "Evento de depuración", + "limited": "Se muestra un subconjunto. Consulte todas las ejecuciones en el panel.", + "queued": "En cola", + "running": "Procesando", + "waiting": "Esperando entrada", + "tool": "Ejecutando herramienta", + "thinking": "Pensando", + "replying": "Preparando respuesta", + "generating": "Generando respuesta", + "completed": "Completado", + "failed": "Fallido", + "cancelled": "Cancelado", + "ignored": "Ignorado" + }, assistant: { movePosition: 'Mover el asistente horizontalmente', resizeHeight: 'Ajustar la altura del asistente', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 39f301ce7..1e928b045 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/ja-JP'; const jaJP = { + inflight: { + autoCollapse: "自動折りたたみ", + empty: "処理中のイベントはありません", + move: "上下にドラッグ、または矢印キーで移動", + botLogs: "ボットのログ", + processorLogs: "プロセッサーのログ", + + "title": "実行中", + "expand": "展開", + "collapse": "折りたたむ", + "reconnecting": "再接続中。最後に受信した状態を表示しています。", + "details": "詳細を見る", + "debug": "デバッグイベント", + "limited": "一部のみ表示しています。全実行はダッシュボードで確認できます。", + "queued": "待機中", + "running": "処理中", + "waiting": "入力待ち", + "tool": "ツール呼び出し中", + "thinking": "思考中", + "replying": "返信準備中", + "generating": "返信生成中", + "completed": "完了", + "failed": "失敗", + "cancelled": "キャンセル済み", + "ignored": "無視" + }, assistant: { movePosition: 'アシスタントを左右に移動', resizeHeight: 'アシスタントの高さを調整', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 2e8e73fcd..4d7365660 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/ru-RU'; const ruRU = { + inflight: { + autoCollapse: "Автосворачивание", + empty: "Нет событий в обработке", + move: "Перетащите по вертикали или используйте стрелки", + botLogs: "Журнал бота", + processorLogs: "Журнал обработчика", + + "title": "В работе", + "expand": "Развернуть", + "collapse": "Свернуть", + "reconnecting": "Переподключение; показано последнее полученное состояние.", + "details": "Подробнее", + "debug": "Отладочное событие", + "limited": "Показана часть запусков. Все запуски доступны на панели мониторинга.", + "queued": "В очереди", + "running": "Обработка", + "waiting": "Ожидание ввода", + "tool": "Вызов инструмента", + "thinking": "Размышление", + "replying": "Подготовка ответа", + "generating": "Генерация ответа", + "completed": "Завершено", + "failed": "Ошибка", + "cancelled": "Отменено", + "ignored": "Пропущено" + }, assistant: { movePosition: 'Переместить помощника по горизонтали', resizeHeight: 'Изменить высоту помощника', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index c730ebe99..56825b302 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/th-TH'; const thTH = { + inflight: { + autoCollapse: "ยุบอัตโนมัติ", + empty: "ไม่มีเหตุการณ์ที่กำลังประมวลผล", + move: "ลากขึ้นลงหรือใช้ปุ่มลูกศรเพื่อย้าย", + botLogs: "บันทึกบอต", + processorLogs: "บันทึกตัวประมวลผล", + + "title": "กำลังดำเนินการ", + "expand": "ขยาย", + "collapse": "ย่อ", + "reconnecting": "กำลังเชื่อมต่อใหม่ แสดงสถานะล่าสุดที่ได้รับ", + "details": "ดูรายละเอียด", + "debug": "เหตุการณ์ทดสอบ", + "limited": "แสดงบางรายการ ดูทั้งหมดได้ที่แดชบอร์ด", + "queued": "อยู่ในคิว", + "running": "กำลังประมวลผล", + "waiting": "รอข้อมูล", + "tool": "กำลังเรียกเครื่องมือ", + "thinking": "กำลังคิด", + "replying": "กำลังเตรียมคำตอบ", + "generating": "กำลังสร้างคำตอบ", + "completed": "เสร็จสิ้น", + "failed": "ล้มเหลว", + "cancelled": "ยกเลิกแล้ว", + "ignored": "ละเว้นแล้ว" + }, assistant: { movePosition: 'เลื่อนผู้ช่วยในแนวนอน', resizeHeight: 'ปรับความสูงของผู้ช่วย', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index d31b6ebf4..c4bdb6e7b 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/vi-VN'; const viVN = { + inflight: { + autoCollapse: "Tự thu gọn", + empty: "Không có sự kiện đang xử lý", + move: "Kéo lên xuống hoặc dùng phím mũi tên", + botLogs: "Nhật ký bot", + processorLogs: "Nhật ký bộ xử lý", + + "title": "Đang thực hiện", + "expand": "Mở rộng", + "collapse": "Thu gọn", + "reconnecting": "Đang kết nối lại; hiển thị trạng thái nhận được gần nhất.", + "details": "Xem chi tiết", + "debug": "Sự kiện gỡ lỗi", + "limited": "Chỉ hiển thị một phần. Xem bảng điều khiển để biết tất cả lượt chạy.", + "queued": "Đang chờ", + "running": "Đang xử lý", + "waiting": "Chờ nhập liệu", + "tool": "Đang gọi công cụ", + "thinking": "Đang suy nghĩ", + "replying": "Chuẩn bị trả lời", + "generating": "Đang tạo câu trả lời", + "completed": "Hoàn tất", + "failed": "Thất bại", + "cancelled": "Đã hủy", + "ignored": "Đã bỏ qua" + }, assistant: { movePosition: 'Di chuyển trợ lý theo chiều ngang', resizeHeight: 'Điều chỉnh chiều cao trợ lý', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 78d309f4d..80b641b06 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/zh-Hans'; const zhHans = { + inflight: { + autoCollapse: "自动折叠", + empty: "当前没有正在处理的事件", + move: "上下拖动;也可用方向键移动", + botLogs: "机器人日志", + processorLogs: "处理器日志", + + "title": "处理中", + "expand": "展开", + "collapse": "收起", + "reconnecting": "连接中断,正在重连;显示的是最后收到的状态。", + "details": "查看详情", + "debug": "调试事件", + "limited": "仅展示部分执行,请在仪表盘查看全部记录。", + "queued": "排队中", + "running": "处理中", + "waiting": "等待用户输入", + "tool": "调用工具", + "thinking": "思考中", + "replying": "准备回复", + "generating": "生成回复", + "completed": "已完成", + "failed": "失败", + "cancelled": "已取消", + "ignored": "已忽略" + }, assistant: { movePosition: '左右移动助手', resizeHeight: '调整助手高度', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index f1292a130..7534359f6 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1,5 +1,31 @@ import pipelineMigration from './pipeline-migration/zh-Hant'; const zhHant = { + inflight: { + autoCollapse: "自動摺疊", + empty: "目前沒有正在處理的事件", + move: "上下拖動;也可用方向鍵移動", + botLogs: "機器人日誌", + processorLogs: "處理器日誌", + + "title": "處理中", + "expand": "展開", + "collapse": "收起", + "reconnecting": "連線中斷,正在重新連線;顯示最後收到的狀態。", + "details": "查看詳情", + "debug": "除錯事件", + "limited": "僅顯示部分執行,請在儀表板查看全部記錄。", + "queued": "排隊中", + "running": "處理中", + "waiting": "等待使用者輸入", + "tool": "呼叫工具", + "thinking": "思考中", + "replying": "準備回覆", + "generating": "產生回覆", + "completed": "已完成", + "failed": "失敗", + "cancelled": "已取消", + "ignored": "已忽略" + }, assistant: { movePosition: '左右移動助手', resizeHeight: '調整助手高度',