mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 23:45:22 +08:00
feat(monitoring): add shared SSE in-flight monitor with draggable dock
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
status={isTogglingEnable ? 'pending' : botEnabled ? 'enabled' : 'disabled'}
|
||||
status={
|
||||
isTogglingEnable
|
||||
? 'pending'
|
||||
: botEnabled
|
||||
? 'enabled'
|
||||
: 'disabled'
|
||||
}
|
||||
className="gap-1.5 rounded-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isTogglingEnable
|
||||
? <Loader2 className="animate-spin" aria-hidden="true" />
|
||||
: <span className="size-1.5 rounded-full bg-current" aria-hidden="true" />}
|
||||
{t(isTogglingEnable
|
||||
? botEnabled ? 'bots.enabling' : 'bots.disabling'
|
||||
: botEnabled ? 'bots.enableConfirmed' : 'bots.disableConfirmed')}
|
||||
{isTogglingEnable ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-current"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{t(
|
||||
isTogglingEnable
|
||||
? botEnabled
|
||||
? 'bots.enabling'
|
||||
: 'bots.disabling'
|
||||
: botEnabled
|
||||
? 'bots.enableConfirmed'
|
||||
: 'bots.disableConfirmed',
|
||||
)}
|
||||
</Badge>
|
||||
<Switch
|
||||
id="bot-enable-switch"
|
||||
@@ -256,7 +298,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
data-guide="bot-config-save"
|
||||
>
|
||||
{t(formDirty && !botEnabled ? 'bots.saveAndEnable' : 'common.save')}
|
||||
{t(
|
||||
formDirty && !botEnabled
|
||||
? 'bots.saveAndEnable'
|
||||
: 'common.save',
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export default function DockIcon({ kind }: { kind: string }) {
|
||||
const paths: Record<string, string> = {
|
||||
event: 'M13 2 4 14h7l-1 8 10-12h-7l0-8z',
|
||||
activity: 'M3 12h4l3-8 4 16 3-8h4',
|
||||
bot: 'M8 4h8v3H8z M5 7h14v13H5z M9 12h.01 M15 12h.01 M9 16h6',
|
||||
pipeline: 'M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7',
|
||||
agent: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M4 21v-2a8 8 0 0 1 16 0v2',
|
||||
processor: 'M9 3h6v6h6v6h-6v6H9v-6H3V9h6z',
|
||||
completed: 'm5 12 4 4L19 6',
|
||||
failed: 'm6 6 12 12 M6 18 18 6',
|
||||
cancelled: 'm6 6 12 12 M6 18 18 6',
|
||||
queued: 'M8 5v14 M16 5v14',
|
||||
waiting: 'M8 5v14 M16 5v14',
|
||||
move: 'm8 6 4-4 4 4 M12 2v20 m-4-4 4 4 4-4',
|
||||
collapse: 'm9 5 7 7-7 7',
|
||||
};
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={paths[kind] || paths.activity} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useSidebarData } from '../home-sidebar/SidebarDataContext';
|
||||
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { executionProcessorLabel } from '../../monitoring/components/executions/ExecutionTable';
|
||||
import DockIcon from './DockIcon';
|
||||
import styles from './inflight.module.css';
|
||||
|
||||
export default function ExecutionNodes({
|
||||
row,
|
||||
processorUrl,
|
||||
running,
|
||||
}: {
|
||||
row: ExecutionRow;
|
||||
processorUrl: string | null;
|
||||
running: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { bots, pipelines } = useSidebarData();
|
||||
const bot = bots.find((item) => item.id === row.bot_id);
|
||||
const processor = pipelines.find((item) => item.id === row.target_id);
|
||||
const nodes = [
|
||||
{
|
||||
name: bot?.name || row.bot_name || t('inflight.debug'),
|
||||
iconURL: bot?.iconURL,
|
||||
emoji: bot?.emoji,
|
||||
kind: 'bot',
|
||||
url: row.bot_id
|
||||
? `/home/bots?id=${encodeURIComponent(row.bot_id)}&tab=logs`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
name: processor?.name || executionProcessorLabel(row),
|
||||
iconURL: processor?.iconURL,
|
||||
emoji: processor?.emoji,
|
||||
kind:
|
||||
row.target_kind === 'event_processor' ? 'processor' : row.target_kind,
|
||||
url: processorUrl,
|
||||
},
|
||||
];
|
||||
const node = (index: number) => {
|
||||
const item = nodes[index];
|
||||
const content = (
|
||||
<>
|
||||
<Avatar className="size-5 shrink-0 rounded">
|
||||
{item.iconURL && (
|
||||
<AvatarImage src={item.iconURL} alt="" className="object-contain" />
|
||||
)}
|
||||
<AvatarFallback className="rounded bg-transparent text-base [&_svg]:size-4">
|
||||
{item.emoji || <DockIcon kind={item.kind} />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-[11px]">{item.name}</span>
|
||||
</>
|
||||
);
|
||||
const className =
|
||||
'flex h-8 min-w-0 max-w-[125px] items-center gap-1.5 rounded-md bg-muted/40 px-1.5';
|
||||
return item.url ? (
|
||||
<Link
|
||||
to={item.url}
|
||||
title={`${index === 0 ? t('inflight.botLogs') : t('inflight.processorLogs')}: ${item.name}`}
|
||||
aria-label={`${index === 0 ? t('inflight.botLogs') : t('inflight.processorLogs')}: ${item.name}`}
|
||||
className={`${className} hover:bg-muted hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center">
|
||||
{node(0)}
|
||||
<span
|
||||
className={`w-5 shrink-0 ${styles.connection} ${running ? styles.flowing : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i />
|
||||
</span>
|
||||
{node(1)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { InflightSnapshot } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import ExecutionDetailSheet, {
|
||||
type ExecutionSelection,
|
||||
} from '@/app/home/monitoring/components/executions/ExecutionDetailSheet';
|
||||
import { executionStatusLabel } from '@/app/home/monitoring/components/executions/ExecutionTable';
|
||||
import styles from './inflight.module.css';
|
||||
import ExecutionNodes from './ExecutionNodes';
|
||||
import { useEdgeDock } from './useEdgeDock';
|
||||
import DockIcon from './DockIcon';
|
||||
|
||||
type Row = InflightSnapshot['items'][number] & { settledAt?: number };
|
||||
const keyOf = (row: Row) => `${row.source}:${row.id}`;
|
||||
const active = (row: Row) => ['running', 'queued'].includes(row.status_group);
|
||||
|
||||
function stage(row: Row): string {
|
||||
if (!active(row)) return row.status_group;
|
||||
if (row.status_group === 'queued') return 'queued';
|
||||
const event = row.progress_event || '';
|
||||
if (event.startsWith('interaction.')) return 'waiting';
|
||||
if (event.startsWith('tool.') && !event.endsWith('completed')) return 'tool';
|
||||
if (event.includes('reasoning')) return 'thinking';
|
||||
if (event === 'message.completed') return 'replying';
|
||||
if (event.startsWith('message.')) return 'generating';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function WorkspaceMonitor() {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const dock = useEdgeDock(true);
|
||||
const { collapsed, setCollapsed } = dock;
|
||||
const [connected, setConnected] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [selected, setSelected] = useState<ExecutionSelection | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const known = useRef(new Map<string, Row>());
|
||||
const [autoCollapse, setAutoCollapse] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('langbot.inflight.autoCollapse') !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// Keep terminal results visible until their exit animation has finished.
|
||||
const hasWork = total > 0 || rows.length > 0;
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'langbot.inflight.autoCollapse',
|
||||
String(autoCollapse),
|
||||
);
|
||||
} catch {
|
||||
/* Optional preference. */
|
||||
}
|
||||
}, [autoCollapse]);
|
||||
useEffect(() => {
|
||||
if (autoCollapse) setCollapsed(!hasWork);
|
||||
}, [autoCollapse, hasWork, setCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let first = true;
|
||||
let controller: AbortController | undefined;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
let failures = 0;
|
||||
const seen = new Set<string>();
|
||||
const accept = (snapshot: InflightSnapshot) => {
|
||||
const time = Date.now();
|
||||
const present = new Set(snapshot.items.map(keyOf));
|
||||
// A reconnect is a fresh authoritative snapshot. Do not leave vanished
|
||||
// executions spinning forever or infer successful completion from absence.
|
||||
if (!snapshot.truncated) {
|
||||
for (const [key, row] of known.current) {
|
||||
if (active(row) && !present.has(key)) known.current.delete(key);
|
||||
}
|
||||
}
|
||||
for (const row of snapshot.items) {
|
||||
const key = keyOf(row);
|
||||
const previous = known.current.get(key);
|
||||
if (active(row) || previous || (!first && !seen.has(key))) {
|
||||
known.current.set(key, {
|
||||
...row,
|
||||
settledAt: active(row) ? undefined : (previous?.settledAt ?? time),
|
||||
});
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
// Retain terminal results briefly, including runs completed between frames.
|
||||
for (const [key, row] of known.current) {
|
||||
if (row.settledAt && time - row.settledAt > 8000)
|
||||
known.current.delete(key);
|
||||
}
|
||||
while (seen.size > 2000) seen.delete(seen.values().next().value!);
|
||||
while (known.current.size > 200)
|
||||
known.current.delete(known.current.keys().next().value!);
|
||||
first = false;
|
||||
setRows([...known.current.values()]);
|
||||
setTotal(snapshot.active_total);
|
||||
setTruncated(snapshot.truncated);
|
||||
};
|
||||
const connect = async () => {
|
||||
if (disposed || document.hidden) return;
|
||||
controller = new AbortController();
|
||||
const connection = controller;
|
||||
try {
|
||||
await backendClient.streamInflight((frame) => {
|
||||
if (disposed || connection.signal.aborted) return;
|
||||
failures = 0;
|
||||
if (frame.kind !== 'heartbeat')
|
||||
setConnected(frame.kind !== 'unavailable');
|
||||
if (frame.kind === 'snapshot') accept(frame.data);
|
||||
}, connection.signal);
|
||||
} catch {
|
||||
if (!disposed && !connection.signal.aborted) {
|
||||
setConnected(false);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
if (!disposed && !connection.signal.aborted && !document.hidden) {
|
||||
retry = setTimeout(
|
||||
connect,
|
||||
Math.min(30000, 1000 * 2 ** failures) + Math.random() * 500,
|
||||
);
|
||||
}
|
||||
};
|
||||
const visibility = () => {
|
||||
clearTimeout(retry);
|
||||
controller?.abort();
|
||||
if (!document.hidden) retry = setTimeout(connect, 100);
|
||||
};
|
||||
void connect();
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
const tick = setInterval(() => {
|
||||
const time = Date.now();
|
||||
let changed = false;
|
||||
for (const [key, row] of known.current) {
|
||||
if (row.settledAt && time - row.settledAt > 8000) {
|
||||
known.current.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) setRows([...known.current.values()]);
|
||||
if (known.current.size) setNow(time);
|
||||
}, 1000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
clearTimeout(retry);
|
||||
clearInterval(tick);
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ordered = [...rows].sort(
|
||||
(a, b) =>
|
||||
Number(active(b)) - Number(active(a)) ||
|
||||
(b.created_at_ms ?? 0) - (a.created_at_ms ?? 0),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
ref={dock.panel}
|
||||
style={{ width: collapsed ? 26 : 'min(320px, calc(100vw - 24px))' }}
|
||||
className={`fixed top-0 right-0 z-40 max-h-[calc(100dvh-24px)] overflow-y-auto rounded-l-xl border border-r-0 bg-popover text-popover-foreground shadow-lg ${styles.panel}`}
|
||||
aria-label={t('inflight.title')}
|
||||
>
|
||||
<div
|
||||
key={collapsed ? 'collapsed' : 'expanded'}
|
||||
className={styles.dockContent}
|
||||
>
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
{...dock.handle}
|
||||
className="flex h-12 w-full touch-none select-none flex-col items-center justify-center gap-0.5 bg-[#2288ee] text-white hover:bg-[#2277e0] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing"
|
||||
title={`${t('inflight.expand')} · ${t('inflight.move')}`}
|
||||
aria-label={`${t('inflight.title')}: ${total}. ${t('inflight.expand')}. ${t('inflight.move')}`}
|
||||
aria-expanded={false}
|
||||
onClick={(event) => {
|
||||
if (event.detail === 0 || !dock.suppressClick.current)
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<DockIcon kind="activity" />
|
||||
<span
|
||||
className={`text-[10px] font-mono ${connected ? '' : 'text-amber-100'}`}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
{...dock.handle}
|
||||
aria-label={t('inflight.move')}
|
||||
title={t('inflight.move')}
|
||||
className="flex min-w-0 flex-1 touch-none select-none items-center gap-2 rounded py-1 text-xs font-medium cursor-grab active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className={connected ? 'text-primary' : 'text-amber-600'}>
|
||||
<DockIcon kind="activity" />
|
||||
</span>
|
||||
{t('inflight.title')}{' '}
|
||||
<span className="font-mono text-muted-foreground">{total}</span>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
<DockIcon kind="move" />
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 shrink-0 gap-1 px-2 text-[10px] ${autoCollapse ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground'}`}
|
||||
aria-pressed={autoCollapse}
|
||||
title={t('inflight.autoCollapse')}
|
||||
onClick={() => setAutoCollapse((enabled) => !enabled)}
|
||||
>
|
||||
{autoCollapse && <DockIcon kind="completed" />}
|
||||
{t('inflight.autoCollapse')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t('inflight.collapse')}
|
||||
title={t('inflight.collapse')}
|
||||
aria-expanded={true}
|
||||
onClick={() => setCollapsed(true)}
|
||||
>
|
||||
<DockIcon kind="collapse" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
{!connected && (
|
||||
<p
|
||||
className="border-t px-4 py-2 text-xs text-amber-600"
|
||||
role="status"
|
||||
>
|
||||
{t('inflight.reconnecting')}
|
||||
</p>
|
||||
)}
|
||||
<div className="max-h-[min(420px,55dvh)] overflow-y-auto border-t">
|
||||
{rows.length === 0 && connected && (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 px-4 py-8 text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<DockIcon kind="completed" />
|
||||
<p className="text-xs">{t('inflight.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div
|
||||
key={keyOf(row)}
|
||||
className={`${styles.task} ${leaving ? styles.leaving : ''} border-b px-3 py-2 last:border-b-0`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
row.has_error ? 'text-destructive' : 'text-primary'
|
||||
}
|
||||
title={label}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<DockIcon kind={active(row) ? 'event' : phase} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(row)}
|
||||
className="min-w-0 flex-1 truncate rounded py-1 text-left text-xs hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`${t('inflight.details')}: ${row.input_preview || row.event_type || row.title}`}
|
||||
aria-label={`${t('inflight.details')}: ${row.event_type || row.title}`}
|
||||
>
|
||||
{t(eventKey, {
|
||||
defaultValue: row.event_type || row.title,
|
||||
})}
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{elapsed}s
|
||||
</span>
|
||||
</div>
|
||||
<ExecutionNodes
|
||||
row={row}
|
||||
processorUrl={processorUrl}
|
||||
running={active(row) && connected}
|
||||
/>
|
||||
<div
|
||||
className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
{...(!active(row)
|
||||
? {
|
||||
'aria-valuenow': 100,
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${row.has_error ? 'bg-destructive' : 'bg-primary'} ${active(row) && connected ? styles.active : 'w-full'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(truncated || rows.length > 20) && (
|
||||
<p className="border-t px-4 py-2 text-xs text-muted-foreground">
|
||||
{t('inflight.limited')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<ExecutionDetailSheet
|
||||
row={selected}
|
||||
onSelect={setSelected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InFlightMonitor() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
if (!workspace?.permissions.includes('resource.view')) return null;
|
||||
return <WorkspaceMonitor key={workspace.workspace.uuid} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLElement>(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<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
<SurveyWidget />
|
||||
<WorkspaceAssistant />
|
||||
<InFlightMonitor />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<void> {
|
||||
const response = await this.instance.get<ReadableStream<Uint8Array>>(
|
||||
'/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[];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'アシスタントの高さを調整',
|
||||
|
||||
@@ -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: 'Изменить высоту помощника',
|
||||
|
||||
@@ -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: 'ปรับความสูงของผู้ช่วย',
|
||||
|
||||
@@ -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ý',
|
||||
|
||||
@@ -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: '调整助手高度',
|
||||
|
||||
@@ -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: '調整助手高度',
|
||||
|
||||
Reference in New Issue
Block a user