feat(monitoring): unify execution views and trace tool calls

This commit is contained in:
Hyu
2026-10-07 22:36:10 +08:00
parent de886edc1b
commit 9f76037e64
42 changed files with 4181 additions and 889 deletions
@@ -194,6 +194,21 @@ class AgentRunOrchestrator:
state_context = build_state_context(event, binding, descriptor)
run_id = context['run_id']
# Monitoring attributes every call to the processor that ran it and to the
# record that owns it; a Runner has to claim both itself, since no
# Pipeline did. Runs before the Runner is invoked and before the run
# context is handed to it, so its own variables stay private.
from ...pipeline import monitoring_helper
await monitoring_helper.prepare_monitoring_identity(
self.ap,
execution_query,
processor_type=binding.processor_type,
processor_id=binding.processor_id,
runner_id=binding.runner_id,
execution_record_id=run_id,
)
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
for tool in resources.get('tools', [])
@@ -50,6 +50,47 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=metrics)
@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."""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
agent_ids = quart.request.args.getlist('agentId')
statuses = quart.request.args.getlist('status')
source = quart.request.args.get('source', 'all')
mode = quart.request.args.get('mode', 'all')
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
result = await self.ap.monitoring_service.get_executions(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
agent_ids=agent_ids if agent_ids else None,
statuses=statuses if statuses else None,
source=source,
mode=mode,
start_time=start_time,
end_time=end_time,
limit=quart.request.args.get('limit', 50),
offset=quart.request.args.get('offset', 0),
)
return self.success(data=result)
@self.route(
'/executions/<source>/<execution_id>',
methods=['GET'],
permission=Permission.RESOURCE_VIEW,
)
async def get_execution_detail(source: str, execution_id: str, request_context: RequestContext) -> str:
"""Full trace of a single execution (agent run or pipeline query)."""
try:
detail = await self.ap.monitoring_service.get_execution_detail(request_context, source, execution_id)
except ValueError as exc:
return self.fail(404, str(exc))
return self.success(data=detail)
@self.route('/token-statistics', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_token_statistics(request_context: RequestContext) -> str:
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
@@ -639,7 +680,7 @@ class MonitoringRouterGroup(group.RouterGroup):
'platform',
]
else:
return self.error(message=f'Invalid export type: {export_type}', code=400)
return self.fail(400, f'Invalid export type: {export_type}')
# Generate CSV content with UTF-8 BOM for Excel compatibility
import io
@@ -10,7 +10,10 @@ from sqlalchemy.dialects import postgresql as postgresql_dialect
from sqlalchemy.dialects import sqlite as sqlite_dialect
from ....core import app
from ....entity.persistence import agent as persistence_agent
from ....entity.persistence import monitoring as persistence_monitoring
from ....entity.persistence import agent_run as persistence_agent_run
from ....entity.persistence import agent_interaction as persistence_agent_interaction
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
@@ -29,6 +32,76 @@ _HARD_MAX_MONITORING_OFFSET = 10000000
_DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
# Canonical execution status groups shared by the agent ledger and pipeline query
# records so both object types can be summarised with one vocabulary.
_AGENT_STATUS_GROUP = {
'completed': 'completed',
'failed': 'failed',
'timeout': 'failed',
'cancelled': 'cancelled',
'created': 'queued',
'queued': 'queued',
'claimed': 'running',
'running': 'running',
}
_PIPELINE_STATUS_GROUP = {
'success': 'completed',
'error': 'failed',
'pending': 'running',
'discarded': 'ignored',
'not_matched': 'ignored',
}
def _status_group(status: str, source: str) -> str:
table = _AGENT_STATUS_GROUP if source == 'agent' else _PIPELINE_STATUS_GROUP
return table.get(status, status)
def _epoch_ms(value: datetime.datetime | None) -> int | None:
"""Epoch milliseconds for a DB timestamp, treating naive values as UTC."""
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=datetime.timezone.utc)
return int(round(value.timestamp() * 1000))
def _percentile_ms(values: list[float], fraction: float) -> int | None:
"""Nearest-rank percentile (ms) over a non-empty, prefix-sorted list."""
if not values:
return None
rank = max(1, min(len(values), int(round(fraction * len(values)))))
return int(round(values[rank - 1]))
def _collect_text(node, out: list[str], depth: int = 0) -> None:
"""Best-effort extraction of human text from a serialised message chain."""
if depth > 6:
return
if isinstance(node, dict):
for key, value in node.items():
if key == 'text' and isinstance(value, str):
out.append(value)
elif isinstance(value, (dict, list)):
_collect_text(value, out, depth + 1)
elif isinstance(node, list):
for item in node:
_collect_text(item, out, depth + 1)
def _message_preview(raw: str | None, limit: int = 160) -> str:
if not raw:
return ''
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return raw[:limit]
parts: list[str] = []
_collect_text(parsed, parts)
text = ' '.join(part.strip() for part in parts if part.strip())
return (text or raw)[:limit]
def _normalize_user_id(value: str | int | None) -> str | None:
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
@@ -885,6 +958,10 @@ class MonitoringService:
active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query)
active_sessions = active_sessions_result.scalar() or 0
# Execution-first KPI values (runs + pipeline queries, latency, token
# coverage) are served by `get_executions`, which applies the execution
# view filters. This endpoint stays the message/traffic rollup so the
# dashboard does not compute the same rollup twice per page load.
return {
'total_messages': total_messages,
'llm_calls': llm_calls,
@@ -894,6 +971,527 @@ class MonitoringService:
'active_sessions': active_sessions,
}
# ========== Execution view ==========
def _agent_run_conditions(
self,
workspace_uuid: str,
*,
bot_ids: list[str] | None = None,
agent_ids: list[str] | None = None,
statuses: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
debug: bool | None = None,
) -> list:
AgentRun = persistence_agent_run.AgentRun
conditions = [AgentRun.workspace_id == workspace_uuid]
if bot_ids:
conditions.append(AgentRun.bot_id.in_(bot_ids))
if agent_ids:
conditions.append(AgentRun.agent_id.in_(agent_ids))
if statuses is not None:
conditions.append(AgentRun.status.in_(statuses) if statuses else sqlalchemy.false())
if start_time:
conditions.append(AgentRun.created_at >= start_time)
if end_time:
conditions.append(AgentRun.created_at <= end_time)
if debug is not None:
debug_condition = sqlalchemy.or_(
AgentRun.binding_id.like('debug:%'),
AgentRun.conversation_id.like('debug:%'),
)
conditions.append(debug_condition if debug else sqlalchemy.not_(debug_condition))
return conditions
def _pipeline_execution_conditions(
self,
workspace_uuid: str,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
statuses: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> list:
MonitoringMessage = persistence_monitoring.MonitoringMessage
conditions = [
MonitoringMessage.workspace_uuid == workspace_uuid,
# Assistant replies are outputs of a query, not an execution of their own.
sqlalchemy.or_(MonitoringMessage.role.is_(None), MonitoringMessage.role != 'assistant'),
]
if bot_ids:
conditions.append(MonitoringMessage.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(MonitoringMessage.pipeline_id.in_(pipeline_ids))
if statuses is not None:
conditions.append(MonitoringMessage.status.in_(statuses) if statuses else sqlalchemy.false())
if start_time:
conditions.append(MonitoringMessage.timestamp >= start_time)
if end_time:
conditions.append(MonitoringMessage.timestamp <= end_time)
return conditions
@staticmethod
def _raw_agent_statuses(groups: list[str] | None) -> list[str] | None:
"""Raw agent statuses for a group filter; empty means "no group applies"."""
if not groups:
return None
raw: list[str] = []
for group in groups:
raw.extend(status for status, mapped in _AGENT_STATUS_GROUP.items() if mapped == group)
return raw
@staticmethod
def _raw_pipeline_statuses(groups: list[str] | None) -> list[str] | None:
"""Raw pipeline statuses for a group filter; empty means "no group applies"."""
if not groups:
return None
raw: list[str] = []
for group in groups:
raw.extend(status for status, mapped in _PIPELINE_STATUS_GROUP.items() if mapped == group)
return raw
@staticmethod
def _model_rows(result) -> list:
"""Rows of one ``select(Model)`` executed through ``execute_async``.
The Core keeps the connection result contract, where ``select(Model)``
yields a *flat* row of the model's columns (``row.uuid``, ``row.status``)
rather than a one-item ORM row. ``.scalars()`` would therefore return the
first column - the id - instead of the row, so the rows are taken as-is.
"""
return list(result.all())
@staticmethod
def _deserialize_json(raw: str | None) -> dict:
if not raw:
return {}
try:
value = json.loads(raw)
except (TypeError, ValueError):
return {}
return value if isinstance(value, dict) else {}
async def _agent_names(self, workspace_uuid: str, rows: list) -> dict[str, str]:
"""The processor name of every Agent run in one page, in one query.
A run stores the Agent it executed as a uuid; the list has to name the
processor, so the names are resolved for the whole page at once.
"""
agent_ids = {row.agent_id for row in rows if row.agent_id}
if not agent_ids:
return {}
Agent = persistence_agent.Agent
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Agent.uuid, Agent.name).where(
sqlalchemy.and_(
Agent.workspace_uuid == workspace_uuid,
Agent.uuid.in_(agent_ids),
)
)
)
return {row.uuid: row.name for row in self._model_rows(result)}
def _serialize_agent_execution(self, row, agent_names: dict[str, str] | None = None) -> dict:
metadata = self._deserialize_json(row.metadata_json)
duration_ms = None
if row.started_at and row.finished_at:
duration_ms = int(round((row.finished_at - row.started_at).total_seconds() * 1000))
created_at_ms = _epoch_ms(row.created_at)
started_at_ms = _epoch_ms(row.started_at)
finished_at_ms = _epoch_ms(row.finished_at)
is_debug = str(row.binding_id or '').startswith('debug:') or str(row.conversation_id or '').startswith('debug:')
return {
'source': 'agent',
'id': row.run_id,
'event_id': row.event_id,
'status': row.status,
'status_group': _status_group(row.status, 'agent'),
'title': metadata.get('event_type') or row.queue_name or row.runner_id,
'target_kind': 'agent' if row.agent_id else 'processor',
'target_id': row.agent_id or row.binding_id,
'target_name': metadata.get('agent_name')
or metadata.get('target_name')
or (agent_names or {}).get(row.agent_id or ''),
'bot_id': row.bot_id,
'bot_name': metadata.get('bot_name'),
'pipeline_id': None,
'pipeline_name': None,
'runner_id': row.runner_id,
'conversation_id': row.conversation_id,
'session_id': row.conversation_id,
'created_at_ms': created_at_ms,
'started_at_ms': started_at_ms,
'finished_at_ms': finished_at_ms,
'duration_ms': duration_ms,
'usage': self._deserialize_json(row.usage_json) or None,
'cost': self._deserialize_json(row.cost_json) or None,
'queue_name': row.queue_name,
'debug': is_debug,
'has_error': row.status in {'failed', 'timeout'},
}
def _serialize_pipeline_execution(self, row) -> dict:
created_at_ms = _epoch_ms(row.timestamp)
return {
'source': 'pipeline',
'id': row.id,
'event_id': None,
'status': row.status,
'status_group': _status_group(row.status, 'pipeline'),
'title': _message_preview(row.message_content),
'target_kind': 'pipeline',
'target_id': row.pipeline_id,
'target_name': row.pipeline_name,
'bot_id': row.bot_id,
'bot_name': row.bot_name,
'pipeline_id': row.pipeline_id,
'pipeline_name': row.pipeline_name,
'runner_id': row.runner_name,
'conversation_id': None,
'session_id': row.session_id,
'created_at_ms': created_at_ms,
'started_at_ms': None,
'finished_at_ms': None,
'duration_ms': None,
'usage': None,
'cost': None,
'queue_name': None,
'debug': False,
'has_error': row.status == 'error',
'platform': row.platform,
'user_id': row.user_id,
'user_name': row.user_name,
}
async def _get_token_coverage(
self,
workspace_uuid: str,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
LLMCall = persistence_monitoring.MonitoringLLMCall
conditions = [LLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(LLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(LLMCall.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(LLMCall.timestamp >= start_time)
if end_time:
conditions.append(LLMCall.timestamp <= end_time)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
sqlalchemy.func.count(LLMCall.id),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.total_tokens > 0, 1), else_=0)),
).where(sqlalchemy.and_(*conditions))
)
row = result.first()
calls, total, calls_with_usage = row if row else (0, 0, 0)
return {
'total_tokens': int(total or 0),
'calls': calls or 0,
'calls_with_usage': calls_with_usage or 0,
}
async def _get_execution_summary(
self,
workspace_uuid: str,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
AgentRun = persistence_agent_run.AgentRun
run_conditions = self._agent_run_conditions(
workspace_uuid, bot_ids=bot_ids, start_time=start_time, end_time=end_time
)
run_status_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(AgentRun.status, sqlalchemy.func.count(AgentRun.id))
.where(sqlalchemy.and_(*run_conditions))
.group_by(AgentRun.status)
)
agent_counts = {row[0]: row[1] for row in run_status_result.all()}
agent_total = sum(agent_counts.values())
debug_count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)).where(
sqlalchemy.and_(
*run_conditions,
sqlalchemy.or_(
AgentRun.binding_id.like('debug:%'),
AgentRun.conversation_id.like('debug:%'),
),
)
)
)
debug_count = debug_count_result.scalar() or 0
# Percentiles are computed in Python, so the sample must stay bounded:
# take the most recent completed runs of the window and label the count
# so the UI can state how many runs the numbers rest on.
timing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(AgentRun.started_at, AgentRun.finished_at)
.where(
sqlalchemy.and_(
*run_conditions,
AgentRun.status == 'completed',
AgentRun.started_at.is_not(None),
AgentRun.finished_at.is_not(None),
)
)
.order_by(AgentRun.created_at.desc())
.limit(self._detail_limit())
)
durations = sorted((row[1] - row[0]).total_seconds() * 1000 for row in timing_result.all() if row[0] and row[1])
pipeline_conditions = self._pipeline_execution_conditions(
workspace_uuid, bot_ids=bot_ids, pipeline_ids=pipeline_ids, start_time=start_time, end_time=end_time
)
pipeline_status_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
persistence_monitoring.MonitoringMessage.status,
sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id),
)
.where(sqlalchemy.and_(*pipeline_conditions))
.group_by(persistence_monitoring.MonitoringMessage.status)
)
pipeline_counts = {row[0]: row[1] for row in pipeline_status_result.all()}
pipeline_total = sum(pipeline_counts.values())
# Pending interactions are gated by the same bot/window scope as the runs
# so "waiting input" cannot drift from the rest of the card.
waiting_conditions = [
persistence_agent_interaction.AgentInteraction.workspace_id == workspace_uuid,
persistence_agent_interaction.AgentInteraction.status == 'pending',
]
if bot_ids:
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.bot_id.in_(bot_ids))
if start_time:
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at >= start_time)
if end_time:
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at <= end_time)
waiting_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_agent_interaction.AgentInteraction.id)).where(
sqlalchemy.and_(*waiting_conditions)
)
)
waiting = waiting_result.scalar() or 0
by_status: dict[str, int] = {}
for status, count in agent_counts.items():
group = _status_group(status, 'agent')
by_status[group] = by_status.get(group, 0) + count
for status, count in pipeline_counts.items():
group = _status_group(status, 'pipeline')
by_status[group] = by_status.get(group, 0) + count
completed = by_status.get('completed', 0)
failed = by_status.get('failed', 0)
denominator = completed + failed
total = agent_total + pipeline_total
return {
'total': total,
'by_source': {'agent': agent_total, 'pipeline': pipeline_total},
'by_status': by_status,
'completed': completed,
'failed': failed,
'running': by_status.get('running', 0),
'queued': by_status.get('queued', 0),
'cancelled': by_status.get('cancelled', 0),
'ignored': by_status.get('ignored', 0),
'waiting': waiting,
'success_rate': round(completed / denominator * 100, 2) if denominator > 0 else None,
'denominator': denominator,
'p50_duration_ms': _percentile_ms(durations, 0.5),
'p95_duration_ms': _percentile_ms(durations, 0.95),
'duration_sample': len(durations),
'debug': debug_count,
'real': total - debug_count,
}
async def get_executions(
self,
context: TenantContext,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
agent_ids: list[str] | None = None,
statuses: list[str] | None = None,
source: str = 'all',
mode: str = 'all',
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
"""List executions from both object types (agent runs and pipeline queries)."""
workspace_uuid = require_workspace_uuid(context)
limit, offset = self.normalize_page_window(limit, offset)
fetch_limit = min(limit + offset, self._configured_query_limit('page_rows', 1000, 5000))
if source not in {'all', 'agent', 'pipeline'}:
source = 'all'
if mode not in {'all', 'real', 'debug'}:
mode = 'all'
debug = None if mode == 'all' else (mode == 'debug')
include_agent = source in {'all', 'agent'} and (not pipeline_ids or bool(agent_ids))
include_pipeline = source in {'all', 'pipeline'} and (not agent_ids or bool(pipeline_ids))
items: list[dict] = []
total = 0
if include_agent:
run_conditions = self._agent_run_conditions(
workspace_uuid,
bot_ids=bot_ids,
agent_ids=agent_ids,
statuses=self._raw_agent_statuses(statuses),
start_time=start_time,
end_time=end_time,
debug=debug,
)
AgentRun = persistence_agent_run.AgentRun
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(AgentRun)
.where(sqlalchemy.and_(*run_conditions))
.order_by(AgentRun.created_at.desc(), AgentRun.id.desc())
.limit(fetch_limit)
)
rows = self._model_rows(result)
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)).where(sqlalchemy.and_(*run_conditions))
)
total += count_result.scalar() or 0
agent_names = await self._agent_names(workspace_uuid, rows)
items.extend(self._serialize_agent_execution(row, agent_names=agent_names) for row in rows)
if include_pipeline and (debug is None or debug is False):
message_conditions = self._pipeline_execution_conditions(
workspace_uuid,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
statuses=self._raw_pipeline_statuses(statuses),
start_time=start_time,
end_time=end_time,
)
MonitoringMessage = persistence_monitoring.MonitoringMessage
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringMessage)
.where(sqlalchemy.and_(*message_conditions))
.order_by(MonitoringMessage.timestamp.desc())
.limit(fetch_limit)
)
rows = self._model_rows(result)
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(MonitoringMessage.id)).where(
sqlalchemy.and_(*message_conditions)
)
)
total += count_result.scalar() or 0
items.extend(self._serialize_pipeline_execution(row) for row in rows)
items.sort(key=lambda item: item.get('created_at_ms') or 0, reverse=True)
page = items[offset : offset + limit]
return {
'items': page,
'total': total,
'limit': limit,
'offset': offset,
'has_more': total > offset + len(page),
'summary': {
'executions': await self._get_execution_summary(
workspace_uuid,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
start_time=start_time,
end_time=end_time,
),
'tokens': await self._get_token_coverage(
workspace_uuid,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
start_time=start_time,
end_time=end_time,
),
},
}
async def get_execution_detail(
self,
context: TenantContext,
source: str,
execution_id: str,
) -> dict:
"""Full trace of a single execution, keyed by object type."""
workspace_uuid = require_workspace_uuid(context)
if source == 'agent':
from ....agent.runner.run_ledger_store import RunLedgerStore
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
run = await store.get_run(execution_id)
if run is None or run.get('workspace_id') != workspace_uuid:
raise ValueError('Execution not found')
items, next_cursor, _, has_more = await store.page_run_events(run_id=execution_id, limit=200)
return {
'source': 'agent',
'run': run,
'events': items,
'has_more': has_more,
'next_cursor': next_cursor,
}
if source == 'pipeline':
MonitoringMessage = persistence_monitoring.MonitoringMessage
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringMessage).where(
sqlalchemy.and_(
MonitoringMessage.id == execution_id,
MonitoringMessage.workspace_uuid == workspace_uuid,
)
)
)
rows = self._model_rows(result)
if not rows:
raise ValueError('Execution not found')
serialized = self.ap.persistence_mgr.serialize_model(MonitoringMessage, rows[0])
async def _related(model, order_column):
related = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(model)
.where(
sqlalchemy.and_(
model.workspace_uuid == workspace_uuid,
model.message_id == execution_id,
)
)
.order_by(order_column.asc())
)
return [self.ap.persistence_mgr.serialize_model(model, row) for row in self._model_rows(related)]
return {
'source': 'pipeline',
'message': serialized,
'llm_calls': await _related(
persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall.timestamp
),
'tool_calls': await _related(
persistence_monitoring.MonitoringToolCall, persistence_monitoring.MonitoringToolCall.timestamp
),
'errors': await _related(
persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError.timestamp
),
}
raise ValueError('Unknown execution source')
async def get_token_statistics(
self,
context: TenantContext,
@@ -18,6 +18,103 @@ if typing.TYPE_CHECKING:
from .pool import get_query_execution_context
async def resolve_processor_name(
ap: app.Application,
workspace_uuid: str,
processor_type: str,
processor_id: str | None,
runner_id: str = '',
) -> str:
"""Display name of the processor kind that handled a run.
Monitoring attributed every call to a Pipeline, so an Agent or a plugin event
processor recorded "Unknown" for the entity that actually ran. Each kind is
resolved from the table that owns it, falling back to the identity the
binding carried.
"""
import sqlalchemy
from ..entity.persistence import agent as persistence_agent
from ..entity.persistence import pipeline as persistence_pipeline
identifier = str(processor_id or '').strip()
if identifier:
try:
model = persistence_pipeline.LegacyPipeline if processor_type == 'pipeline' else persistence_agent.Agent
result = await ap.persistence_mgr.execute_async(
sqlalchemy.select(model.name).where(
sqlalchemy.and_(
model.uuid == identifier,
model.workspace_uuid == workspace_uuid,
)
)
)
name = str(result.scalar() or '').strip()
if name:
return name
except Exception as e:
ap.logger.warning(f'Failed to resolve monitoring processor name: {e}')
# A deleted Agent or an unregistered plugin processor still names the
# component that ran, never a bare "Unknown".
return identifier or runner_id or processor_type
async def prepare_monitoring_identity(
ap: app.Application,
query: pipeline_query.Query,
*,
processor_type: str,
processor_id: str | None,
runner_id: str = '',
execution_record_id: str = '',
) -> None:
"""Pin the monitoring identity of a runner-owned execution.
A Pipeline pins these variables when it starts a query; a Runner started by
an Agent or a plugin event processor has to pin them itself, otherwise every
LLM/tool call it makes is attributed to "Unknown". An enclosing Pipeline
already named the run, so its label is never overwritten.
``_monitoring_message_id`` is the execution record the calls belong to: the
Pipeline's message, or the Agent run's id. Both are the id the executions
list shows, which is what lets a call jump back to its record.
"""
try:
variables = getattr(query, 'variables', None)
if not isinstance(variables, dict):
return
if variables.get('_monitoring_pipeline_name'):
return
bot_name = 'WebChat'
bot_uuid = str(getattr(query, 'bot_uuid', '') or '')
workspace_uuid = str(getattr(query, 'workspace_uuid', '') or '')
if bot_uuid and workspace_uuid:
try:
bot = await ap.bot_service.get_bot(
workspace_uuid,
bot_uuid,
include_secret=False,
)
if bot:
bot_name = bot.get('name', bot_name)
except Exception as e:
ap.logger.warning(f'Failed to resolve monitoring bot name: {e}')
variables['_monitoring_bot_name'] = bot_name
variables['_monitoring_pipeline_name'] = await resolve_processor_name(
ap,
workspace_uuid,
processor_type,
processor_id,
runner_id,
)
if execution_record_id:
variables['_monitoring_message_id'] = execution_record_id
except Exception as e:
ap.logger.error(f'Failed to prepare monitoring identity: {e}')
class MonitoringHelper:
"""Helper class for monitoring operations"""
+74
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import typing
import time
import asyncio
import inspect
from typing import TYPE_CHECKING
@@ -448,6 +449,19 @@ class ToolManager:
start_time = time.perf_counter()
try:
result = await invoke()
except asyncio.CancelledError:
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='error',
error_message='cancelled',
)
self._trace_tool_call(source=source, name=name, query=query, outcome='cancelled', error='cancelled')
raise
except Exception as e:
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
@@ -459,6 +473,14 @@ class ToolManager:
status='error',
error_message=str(e),
)
outcome = 'timeout' if isinstance(e, TimeoutError) else 'failed'
self._trace_tool_call(
source=source,
name=name,
query=query,
outcome=outcome,
error=str(e) or type(e).__name__,
)
raise
duration_ms = int((time.perf_counter() - start_time) * 1000)
@@ -471,8 +493,60 @@ class ToolManager:
status='success',
result=result,
)
self._trace_tool_call(source=source, name=name, query=query, outcome='success')
return result
def _trace_tool_call(
self,
*,
source: str,
name: str,
query: pipeline_query.Query,
outcome: str,
error: str = '',
) -> None:
"""Report one tool invocation as its own step of the execution chain.
Every tool call a Run makes - sandbox, skill, plugin or MCP - is the
observable action of that Run, so the chain shows it under the step that
issued it instead of collapsing the Run into a single opaque node.
An Agent's tool call is executed for the Runtime's RPC task, which never
inherited the chain context. The Run id the caller already carries is the
alias that task's record resolves the owning execution through.
"""
# Imported here: telemetry pulls the application, and the tool manager is
# constructed while the application is still assembling.
from ...telemetry.execution import record as record_execution
from ...telemetry.platform import processing_mode
try:
record_execution(
self.ap,
get_query_execution_context(query),
execution_id=self._trace_execution_id(query),
family='tool',
operation=name,
adapter=source,
mode=processing_mode.get(),
outcome=outcome,
error=error,
)
except Exception as e:
self.ap.logger.warning(f'Failed to record tool call trace: {e}')
@staticmethod
def _trace_execution_id(query: pipeline_query.Query) -> str:
"""The execution chain this call belongs to, when the caller knows it.
``''`` lets the emitter fall back to the task's own trace context, which
is what a Pipeline-lane tool call has.
"""
session = getattr(query, '_agent_run_session', None)
if not isinstance(session, dict):
return ''
return str(session.get('run_id') or '').strip()
async def execute_func_call(
self,
name: str,
+4 -4
View File
@@ -33,7 +33,7 @@ from .trace import TraceState
MODES = frozenset({'pipeline', 'agent', 'event_processor', 'none'})
OUTCOMES = frozenset({'success', 'failed', 'cancelled', 'timeout', 'skipped', 'unknown'})
FAMILIES = frozenset({'platform_event', 'event_route', 'pipeline', 'runner', 'platform_api'})
FAMILIES = frozenset({'platform_event', 'event_route', 'pipeline', 'runner', 'platform_api', 'tool'})
ORIGINS = frozenset({'platform', 'webui', 'api'})
CLOSED_BY = frozenset({'event_done', 'pipeline_done', 'runner_done', 'timeout'})
CHAIN_FAILURES = frozenset({'failed', 'cancelled', 'timeout'})
@@ -255,9 +255,9 @@ class ExecutionCounters:
state = trace_mod.current()
if state is None:
# Cross-task work has no inherited context; resolve the owning
# execution through the registry. It has no open step here, so
# its node becomes a root of that execution instead of being
# mirrored from another task's stack.
# execution through the registry. It owns no step here, so the
# record attaches to the innermost step the chain has open -
# the step that caused this work.
lookup = str(execution_id or '').strip() or current_execution_id()
if lookup:
state = self._resolve(lookup)
+31 -6
View File
@@ -11,12 +11,17 @@ small execution-scoped context the emitter needs:
* the routing/run identity pinned around the block that produced a node;
* the open-step stack, which lives in a ContextVar holding *one task's own
immutable tuple*. Child tasks inherit a snapshot at creation; no shared
mutable stack and no cross-task mirror exists, so sibling tasks can never
corrupt each other's parent links.
mutable stack exists, so sibling tasks can never corrupt each other's parent
links.
Cross-task callers (plugin/RPC work with no inherited context) resolve their
execution through the owner's execution-id registry instead, which only
carries the execution state - never node payloads.
carries the execution state - never node payloads. Such a caller owns no step
of its own, yet its work is still caused by a step of the chain: the sandbox
tool a Runner invoked, the platform API a plugin called back over RPC. The
execution state therefore also keeps the ordered set of steps any task
currently has open, and a record emitted with no open step of its own attaches
to the innermost one of those instead of becoming a root of the chain.
"""
from __future__ import annotations
@@ -77,6 +82,7 @@ class TraceState:
'pipeline_plugins',
'node_adapter',
'node_runner',
'open_frames',
)
def __init__(self, execution_id: str | None = None) -> None:
@@ -110,6 +116,10 @@ class TraceState:
# First adapter/runner seen on a node, used for the chain record.
self.node_adapter = ''
self.node_runner = ''
# Steps currently open anywhere in this chain, in the order they were
# opened. A record emitted by a task that owns no step of its own
# attaches to the innermost entry here.
self.open_frames: dict[str, NodeFrame] = {}
def allocate_seq(self) -> int:
"""Mint the next chain-local node order."""
@@ -117,6 +127,17 @@ class TraceState:
self.node_seq += 1
return seq
def open_lane(self) -> str:
"""The innermost step open in this chain, whichever task opened it."""
frames = list(self.open_frames)
return frames[-1] if frames else ''
def open_step(self, frame: NodeFrame) -> None:
self.open_frames[frame.node_id] = frame
def close_step(self, frame: NodeFrame) -> None:
self.open_frames.pop(frame.node_id, None)
def note_node(
self,
*,
@@ -221,8 +242,10 @@ def resolve_node(
A record stamped with a ``node`` the calling task has open reuses that
step's identity, order and start time; anything else (a plain observation,
or a node opened by another task) is a fresh node under this task's
innermost open step.
or a node opened by another task) is a fresh node under the innermost step
open in this task. A task that inherited no step at all - plugin/RPC work
that only knows the execution - attaches to the innermost step the chain
has open, which is the step that caused the work.
"""
stack = _node.get()
if node:
@@ -231,7 +254,7 @@ def resolve_node(
if frame.node_id == node:
parent = stack[index - 1].node_id if index > 0 else ''
return frame.node_id, frame.seq, frame.started_at, parent
parent = stack[-1].node_id if stack else ''
parent = stack[-1].node_id if stack else state.open_lane()
return node or uuid4().hex, state.allocate_seq(), fallback_started_at, parent
@@ -250,9 +273,11 @@ def stage_scope() -> typing.Iterator[str]:
return
frame = NodeFrame(uuid4().hex, state.allocate_seq(), _now())
token = _node.set(_node.get() + (frame,))
state.open_step(frame)
try:
yield frame.node_id
finally:
state.close_step(frame)
try:
_node.reset(token)
except (ValueError, RuntimeError):
@@ -0,0 +1,350 @@
"""Execution-view tests for the monitoring service.
Covers the unified agent-run + pipeline-query execution list, its rollup
summary, and single-execution traces.
"""
from __future__ import annotations
import datetime
from types import SimpleNamespace
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.entity.persistence import agent_interaction as persistence_interaction
from langbot.pkg.entity.persistence import agent_run as persistence_agent_run
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
from langbot.pkg.entity.persistence import workspace as persistence_workspace # noqa: F401
from langbot.pkg.entity.persistence.base import Base
WORKSPACE = 'workspace-1'
class FakePersistenceManager:
"""Runs statements against a real in-memory SQLite engine."""
def __init__(self, engine):
self._engine = engine
self._factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def get_db_engine(self):
return self._engine
async def execute_async(self, statement):
# Match PersistenceManager's connection-level result shape.
async with self._engine.connect() as connection:
return await connection.execute(statement)
@staticmethod
def serialize_model(model, data):
out = {}
for column in model.__table__.columns:
value = getattr(data, column.name)
out[column.name] = value.isoformat() if isinstance(value, datetime.datetime) else value
return out
@pytest.fixture
async def engine(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "monitoring_executions.db"}')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
def service(engine):
ap = SimpleNamespace(
persistence_mgr=FakePersistenceManager(engine),
instance_config=None,
)
return MonitoringService(ap)
def _dt(hour: int, minute: int = 0, second: int = 0) -> datetime.datetime:
return datetime.datetime(2026, 1, 1, hour, minute, second)
async def _seed(engine) -> None:
factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
session.add_all(
[
persistence_agent_run.AgentRun(
run_id='run-done',
event_id='evt-1',
agent_id='agent-1',
binding_id='agent_agent-1_default',
runner_id='runner-1',
workspace_id=WORKSPACE,
bot_id='bot-1',
status='completed',
created_at=_dt(10),
started_at=_dt(10, 0, 1),
finished_at=_dt(10, 0, 3),
usage_json='{"total_tokens": 42}',
),
persistence_agent_run.AgentRun(
run_id='run-failed',
binding_id='agent_agent-1_default',
runner_id='runner-1',
workspace_id=WORKSPACE,
bot_id='bot-1',
status='failed',
created_at=_dt(10, 5),
),
persistence_agent_run.AgentRun(
run_id='run-debug',
event_id='debug:agent-1:e1',
agent_id='agent-1',
binding_id='debug:agent-1:runner-1',
runner_id='runner-1',
workspace_id=WORKSPACE,
bot_id='bot-1',
status='completed',
created_at=_dt(10, 6),
started_at=_dt(10, 6, 0),
finished_at=_dt(10, 6, 1),
),
persistence_agent_run.AgentRunEvent(
run_id='run-done',
sequence=1,
type='run.completed',
data_json='{"message": {"role": "assistant", "content": "hi"}}',
),
persistence_interaction.AgentInteraction(
interaction_id='int-1',
run_id='run-done',
binding_id='agent_agent-1_default',
runner_id='runner-1',
processor_type='agent',
processor_id='agent-1',
workspace_id=WORKSPACE,
status='pending',
request_json='{}',
callback_token_hash='hash-1',
),
]
)
for message in [
dict(
id='msg-user',
timestamp=_dt(10, 2),
session_id='session-1',
message_content='{"chain": [{"type": "Plain", "text": "hello there"}]}',
status='success',
role='user',
),
dict(
id='msg-assistant',
timestamp=_dt(10, 3),
session_id='session-1',
message_content='{"chain": [{"type": "Plain", "text": "reply"}]}',
status='success',
role='assistant',
),
dict(
id='msg-discarded',
timestamp=_dt(10, 4),
session_id='session-2',
message_content='{"chain": [{"type": "Plain", "text": "ignored"}]}',
status='discarded',
role='user',
),
]:
session.add(
persistence_monitoring.MonitoringMessage(
workspace_uuid=WORKSPACE,
bot_id='bot-1',
bot_name='Bot One',
pipeline_id='pipeline-1',
pipeline_name='Pipeline One',
level='info',
**message,
)
)
session.add_all(
[
persistence_monitoring.MonitoringLLMCall(
id='llm-1',
workspace_uuid=WORKSPACE,
timestamp=_dt(10, 2, 30),
model_name='gpt',
input_tokens=10,
output_tokens=20,
total_tokens=30,
duration=100,
status='success',
bot_id='bot-1',
bot_name='Bot One',
pipeline_id='pipeline-1',
pipeline_name='Pipeline One',
session_id='session-1',
message_id='msg-user',
),
persistence_monitoring.MonitoringLLMCall(
id='llm-2',
workspace_uuid=WORKSPACE,
timestamp=_dt(10, 4, 30),
model_name='gpt',
input_tokens=0,
output_tokens=0,
total_tokens=0,
duration=50,
status='success',
bot_id='bot-1',
bot_name='Bot One',
pipeline_id='pipeline-1',
pipeline_name='Pipeline One',
session_id='session-2',
message_id='msg-discarded',
),
]
)
session.add(
persistence_monitoring.MonitoringSession(
workspace_uuid=WORKSPACE,
bot_id='bot-1',
session_id='session-1',
bot_name='Bot One',
pipeline_id='pipeline-1',
pipeline_name='Pipeline One',
message_count=2,
start_time=_dt(10, 2),
last_activity=_dt(10, 5),
is_active=True,
)
)
await session.commit()
@pytest.mark.asyncio
async def test_executions_merge_and_order_both_sources(engine, service):
await _seed(engine)
result = await service.get_executions(WORKSPACE, limit=50)
assert result['total'] == 5
ids = [(item['source'], item['id']) for item in result['items']]
# Assistant replies are outputs, not executions.
assert ('pipeline', 'msg-assistant') not in ids
# Newest first across both object types.
assert ids == [
('agent', 'run-debug'),
('agent', 'run-failed'),
('pipeline', 'msg-discarded'),
('pipeline', 'msg-user'),
('agent', 'run-done'),
]
by_id = {item['id']: item for item in result['items']}
assert result['summary']['executions']['total'] == 5
assert result['summary']['executions']['denominator'] == 4
assert result['summary']['tokens']['calls'] == 2
assert result['summary']['tokens']['calls_with_usage'] == 1
assert by_id['run-done']['status_group'] == 'completed'
assert by_id['run-failed']['status_group'] == 'failed'
assert by_id['run-debug']['debug'] is True
assert by_id['run-done']['debug'] is False
assert by_id['run-done']['usage'] == {'total_tokens': 42}
assert by_id['run-done']['duration_ms'] == 2000
assert by_id['msg-user']['status_group'] == 'completed'
assert by_id['msg-discarded']['status_group'] == 'ignored'
assert by_id['msg-user']['title'] == 'hello there'
assert by_id['msg-user']['target_id'] == 'pipeline-1'
@pytest.mark.asyncio
async def test_executions_source_status_and_paging(engine, service):
await _seed(engine)
only_agents = await service.get_executions(WORKSPACE, source='agent')
assert {item['source'] for item in only_agents['items']} == {'agent'}
assert only_agents['total'] == 3
pipeline_only = await service.get_executions(WORKSPACE, source='pipeline')
assert {item['source'] for item in pipeline_only['items']} == {'pipeline'}
assert pipeline_only['total'] == 2
# A single-object target selection narrows the union to that source.
by_pipeline = await service.get_executions(WORKSPACE, pipeline_ids=['pipeline-1'])
assert {item['source'] for item in by_pipeline['items']} == {'pipeline'}
failed = await service.get_executions(WORKSPACE, statuses=['failed'])
assert [item['id'] for item in failed['items']] == ['run-failed']
# `ignored` only exists for pipeline queries: it must not widen the agent
# filter back to "every status".
ignored = await service.get_executions(WORKSPACE, statuses=['ignored'])
assert [item['id'] for item in ignored['items']] == ['msg-discarded']
debug_only = await service.get_executions(WORKSPACE, mode='debug')
assert [item['id'] for item in debug_only['items']] == ['run-debug']
first_page = await service.get_executions(WORKSPACE, limit=2)
assert len(first_page['items']) == 2
assert first_page['has_more'] is True
second_page = await service.get_executions(WORKSPACE, limit=2, offset=2)
assert first_page['items'][0]['id'] != second_page['items'][0]['id']
@pytest.mark.asyncio
async def test_execution_summary_uses_explicit_denominator(engine, service):
await _seed(engine)
summary = (await service.get_executions(WORKSPACE))['summary']
executions = summary['executions']
# completed = run-done + run-debug + msg-user; failed = run-failed.
assert executions['by_source'] == {'agent': 3, 'pipeline': 2}
assert executions['completed'] == 3
assert executions['failed'] == 1
assert executions['ignored'] == 1
assert executions['denominator'] == 4
assert executions['success_rate'] == 75.0
assert executions['debug'] == 1
assert executions['real'] == 4
assert executions['waiting'] == 1
assert executions['p50_duration_ms'] == 1000
assert executions['p95_duration_ms'] == 2000
# Only run-done and run-debug are completed runs with a start and a finish.
assert executions['duration_sample'] == 2
# Token coverage is explicit: llm-2 succeeded without usage.
assert summary['tokens'] == {'total_tokens': 30, 'calls': 2, 'calls_with_usage': 1}
@pytest.mark.asyncio
async def test_overview_metrics_keep_message_rollup(engine, service):
await _seed(engine)
overview = await service.get_overview_metrics(WORKSPACE)
assert overview['total_messages'] == 3
assert overview['llm_calls'] == 2
assert overview['model_calls'] == 2
assert overview['active_sessions'] == 1
@pytest.mark.asyncio
async def test_execution_detail_for_agent_and_pipeline(engine, service):
await _seed(engine)
agent_detail = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done')
assert agent_detail['run']['run_id'] == 'run-done'
assert agent_detail['events'][0]['type'] == 'run.completed'
pipeline_detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user')
assert pipeline_detail['message']['id'] == 'msg-user'
assert [call['id'] for call in pipeline_detail['llm_calls']] == ['llm-1']
assert pipeline_detail['tool_calls'] == []
with pytest.raises(ValueError):
await service.get_execution_detail(WORKSPACE, 'agent', 'missing-run')
with pytest.raises(ValueError):
await service.get_execution_detail(WORKSPACE, 'pipeline', 'missing-message')
@@ -7,6 +7,8 @@ Tests cover:
from __future__ import annotations
import asyncio
import contextvars
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -380,6 +382,127 @@ class TestToolManagerExecuteFuncCall:
assert 'MCP resource agent reads are disabled' in record.await_args.kwargs['error_message']
class _RecordingTelemetrySender:
"""Telemetry sender stand-in that keeps every payload it was handed."""
def __init__(self):
self.telemetry_config = {'url': 'https://space.example.test', 'execution_trace': 'all'}
self.sent: list[dict] = []
async def send(self, payload: dict) -> bool:
self.sent.append(payload)
return True
class TestToolCallTracing:
"""Every tool call a Run makes is its own step of the execution chain."""
@pytest.fixture
def traced_app(self):
execution = import_module('langbot.pkg.telemetry.execution')
counters = execution.ExecutionCounters(_RecordingTelemetrySender())
app = Mock()
app.logger = Mock()
app.telemetry = SimpleNamespace(execution=counters)
# Tool-call monitoring is asserted by the tests above; this path only
# needs the execution chain.
app.monitoring_service = None
def _inert_loader():
loader = Mock()
loader.has_tool = AsyncMock(return_value=False)
loader.invoke_tool = AsyncMock(return_value=None)
return loader
app._native_loader = _inert_loader()
app._plugin_loader = _inert_loader()
app._mcp_loader = _inert_loader()
app._skill_loader = _inert_loader()
return app, counters
@pytest.fixture
def sample_query(self):
query = Mock(spec=pipeline_query.Query)
query._execution_context = _CONTEXT
return query
@staticmethod
def _manager(app):
"""ToolManager wired to app-local inert loaders."""
manager = get_toolmgr_module().ToolManager(app)
manager.native_tool_loader = app._native_loader
manager.plugin_tool_loader = app._plugin_loader
manager.mcp_tool_loader = app._mcp_loader
manager.skill_tool_loader = app._skill_loader
return manager
@staticmethod
def _tool_nodes(counters):
return [record for record in counters.records if record['event_type'] == 'execution_node']
@pytest.mark.asyncio
async def test_a_tool_call_becomes_a_node_under_the_step_that_issued_it(self, traced_app, sample_query):
trace = import_module('langbot.pkg.telemetry.trace')
execution = import_module('langbot.pkg.telemetry.execution')
app, counters = traced_app
manager = self._manager(app)
binding = execution.bind_trace(app, 'exec-tool')
# The Runtime's RPC task executes the call with no inherited trace
# context; the Run id on the query is what resolves the owning chain.
sample_query._agent_run_session = {'run_id': 'exec-tool'}
try:
with trace.stage_scope() as lane:
foreign = contextvars.Context()
task = asyncio.get_running_loop().create_task(
manager._invoke_tool_with_monitoring(
source='native',
name='exec',
parameters={'command': 'ls'},
query=sample_query,
invoke=AsyncMock(return_value='sandbox output'),
),
context=foreign,
)
assert await task == 'sandbox output'
finally:
trace.unbind_root(binding)
rows = self._tool_nodes(counters)
assert len(rows) == 1
assert rows[0]['family'] == 'tool'
assert rows[0]['operation'] == 'exec'
assert rows[0]['adapter'] == 'native'
assert rows[0]['outcome'] == 'success'
assert rows[0]['parent_node_id'] == lane
@pytest.mark.asyncio
async def test_a_failed_tool_call_carries_its_reason(self, traced_app, sample_query):
execution = import_module('langbot.pkg.telemetry.execution')
trace = import_module('langbot.pkg.telemetry.trace')
app, counters = traced_app
manager = self._manager(app)
binding = execution.bind_trace(app, 'exec-tool-failed')
try:
with pytest.raises(RuntimeError):
await manager._invoke_tool_with_monitoring(
source='mcp',
name='read_resource',
parameters={},
query=sample_query,
invoke=AsyncMock(side_effect=RuntimeError('server unreachable')),
)
finally:
trace.unbind_root(binding)
rows = self._tool_nodes(counters)
assert len(rows) == 1
assert rows[0]['family'] == 'tool'
assert rows[0]['outcome'] == 'failed'
assert rows[0]['error'] == 'server unreachable'
class TestToolManagerSourceResolution:
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['mcp', 'plugin'])
+46 -3
View File
@@ -344,10 +344,53 @@ class TestTraceNodeScope:
lane_row, foreign_row = nodes(counters)
assert lane_row['node_id'] == lane and lane_row['parent_node_id'] == '' and lane_row['root'] is True
# The cross-task node joins the same execution, but never mirrors another
# task's open-step stack: it is a root of the chain.
# The cross-task node joins the same execution and hangs off the step the
# chain had open - the step that caused the work - instead of mirroring
# any task's own stack.
assert foreign_row['event_id'] == 'exec-cross'
assert foreign_row['parent_node_id'] == ''
assert foreign_row['parent_node_id'] == lane
assert foreign_row['root'] is False
def test_cross_task_observation_attaches_to_the_innermost_open_step(self):
trace, execution = get_modules()
_, counters = make_counters(trace_config())
binding = execution.bind_trace(make_ap(counters), 'exec-nested')
try:
with trace.stage_scope() as outer:
counters.record(CONTEXT, **{**STAGE, 'node': outer})
with trace.stage_scope() as inner:
counters.record(CONTEXT, **{**STAGE, 'node': inner})
foreign = contextvars.Context()
foreign.run(execution.set_execution_id, 'exec-nested')
foreign.run(counters.record, CONTEXT, **{**STAGE, 'family': 'tool', 'operation': 'exec'})
finally:
trace.unbind_root(binding)
outer_row, inner_row, tool_row = nodes(counters)
assert outer_row['parent_node_id'] == ''
assert inner_row['parent_node_id'] == outer
# The lane that caused the cross-task work is the innermost one open.
assert tool_row['parent_node_id'] == inner
def test_cross_task_observation_after_the_lane_closed_is_a_root(self):
trace, execution = get_modules()
_, counters = make_counters(trace_config())
binding = execution.bind_trace(make_ap(counters), 'exec-late')
try:
with trace.stage_scope() as lane:
counters.record(CONTEXT, **{**STAGE, 'node': lane})
# A late callback arrives after the owning step returned: nothing is
# open anymore, so the node is a root of the chain rather than a
# child of a finished step.
foreign = contextvars.Context()
foreign.run(execution.set_execution_id, 'exec-late')
foreign.run(counters.record, CONTEXT, family='platform_api', operation='reply', mode='pipeline')
finally:
trace.unbind_root(binding)
lane_row, late_row = nodes(counters)
assert late_row['parent_node_id'] == '' and late_row['root'] is False
assert lane_row['root'] is True
def test_cross_task_observation_without_an_open_step_has_no_parent(self):
trace, execution = get_modules()
@@ -12,6 +12,7 @@ import {
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { MessageContentRenderer } from './MessageContentRenderer';
import {
ConversationTurn,
@@ -507,10 +508,11 @@ export function ConversationTurnList({
key={call.id}
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
>
<button
<Button
type="button"
variant="ghost"
className={cn(
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
'flex h-auto w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left',
hasToolDetails &&
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
@@ -556,7 +558,7 @@ export function ConversationTurnList({
<span className="shrink-0 text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</button>
</Button>
{hasToolDetails && expandedToolCall && (
<div
@@ -8,6 +8,7 @@ import {
Users,
Layers,
ThumbsUp,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -182,26 +183,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
>
{exporting ? (
<>
<svg
className="w-4 h-4 mr-2 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('monitoring.export.exporting')}
</>
) : (
@@ -9,6 +9,7 @@ import {
Heart,
Smile,
} from 'lucide-react';
import { MetricCard } from './overview-cards/MetricCard';
interface FeedbackCardProps {
title: string;
@@ -23,6 +24,18 @@ interface FeedbackCardProps {
loading?: boolean;
}
// The variant only tints the icon tile; the card surface stays neutral so the
// feedback row matches the token-monitoring cards.
const variantAccents: Record<
NonNullable<FeedbackCardProps['variant']>,
string
> = {
default: '#64748b',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
};
export function FeedbackCard({
title,
value,
@@ -32,89 +45,37 @@ export function FeedbackCard({
variant = 'default',
loading = false,
}: FeedbackCardProps) {
const variantStyles = {
default: 'bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700',
success:
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
warning:
'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
danger: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
};
const iconStyles = {
default: 'text-gray-500 dark:text-gray-400',
success: 'text-green-500 dark:text-green-400',
warning: 'text-yellow-500 dark:text-yellow-400',
danger: 'text-red-500 dark:text-red-400',
};
const trendStyles = {
up: 'text-green-500',
down: 'text-red-500',
neutral: 'text-gray-500',
down: 'text-destructive',
neutral: 'text-muted-foreground',
};
if (loading) {
return (
<div
className={`p-6 rounded-xl border shadow-sm ${variantStyles.default} animate-pulse`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2" />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-1" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</div>
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
</div>
);
}
return (
<div
className={`p-6 rounded-xl border shadow-sm ${variantStyles[variant]}`}
<MetricCard
label={title}
value={value}
hint={subtitle}
icon={icon}
accent={variantAccents[variant]}
loading={loading}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
{title}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{value}
</p>
{subtitle && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{subtitle}
</p>
)}
{trend && (
<div
className={`flex items-center mt-2 text-sm ${trendStyles[trend.direction]}`}
>
{trend.direction === 'up' && (
<TrendingUp className="w-4 h-4 mr-1" />
)}
{trend.direction === 'down' && (
<TrendingDown className="w-4 h-4 mr-1" />
)}
{trend.direction === 'neutral' && (
<Minus className="w-4 h-4 mr-1" />
)}
<span>
{trend.value > 0 ? '+' : ''}
{trend.value}%
</span>
</div>
)}
</div>
{trend && (
<div
className={`p-3 rounded-lg bg-gray-100 dark:bg-gray-800 ${iconStyles[variant]}`}
className={`flex items-center text-sm ${trendStyles[trend.direction]}`}
>
{icon}
{trend.direction === 'up' && <TrendingUp className="mr-1 h-4 w-4" />}
{trend.direction === 'down' && (
<TrendingDown className="mr-1 h-4 w-4" />
)}
{trend.direction === 'neutral' && <Minus className="mr-1 h-4 w-4" />}
<span>
{trend.value > 0 ? '+' : ''}
{trend.value}%
</span>
</div>
</div>
</div>
)}
</MetricCard>
);
}
@@ -135,35 +96,37 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.totalFeedback'),
value: stats?.totalFeedback ?? 0,
icon: <Heart className="w-6 h-6" />,
icon: <Heart className="h-4 w-4" />,
variant: 'default' as const,
},
{
title: t('monitoring.feedback.totalLikes'),
value: stats?.totalLikes ?? 0,
icon: <ThumbsUp className="w-6 h-6" />,
icon: <ThumbsUp className="h-4 w-4" />,
variant: 'success' as const,
},
{
title: t('monitoring.feedback.totalDislikes'),
value: stats?.totalDislikes ?? 0,
icon: <ThumbsDown className="w-6 h-6" />,
icon: <ThumbsDown className="h-4 w-4" />,
variant: 'danger' as const,
},
{
title: t('monitoring.feedback.satisfactionRate'),
value: stats ? `${stats.satisfactionRate}%` : '0%',
icon: <Smile className="w-6 h-6" />,
icon: <Smile className="h-4 w-4" />,
// The rate is the one feedback metric that is good or bad on its own, so
// its tile keeps encoding the verdict (as it did before the restyle).
variant: (stats && stats.satisfactionRate >= 80
? 'success'
: stats && stats.satisfactionRate >= 50
? 'warning'
: 'danger') as 'default' | 'success' | 'warning' | 'danger',
: 'danger') as 'success' | 'warning' | 'danger',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card, index) => (
<FeedbackCard
key={index}
@@ -10,7 +10,9 @@ import {
} from 'lucide-react';
import { FeedbackRecord } from '../types/monitoring';
import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { TabState } from './TabState';
interface FeedbackListProps {
feedback: FeedbackRecord[];
@@ -31,33 +33,25 @@ export function FeedbackList({
};
if (loading) {
return (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
);
return <TabState loading rows={6} />;
}
if (!feedback || feedback.length === 0) {
return (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium mb-2">
{t('monitoring.feedback.noFeedback')}
</p>
<p className="text-sm">
{t('monitoring.feedback.noFeedbackDescription')}
</p>
</div>
<TabState
icon={<Heart className="w-16 h-16 text-muted-foreground/50" />}
title={t('monitoring.feedback.noFeedback')}
hint={t('monitoring.feedback.noFeedbackDescription')}
/>
);
}
return (
<div className="space-y-4">
{feedback.map((item) => (
<div
<Card
key={item.id}
className={`border rounded-xl overflow-hidden hover:shadow-md transition-all duration-200 ${
className={`gap-0 py-0 overflow-hidden hover:shadow-md transition-all duration-200 ${
item.feedbackType === 'like'
? 'border-green-200 dark:border-green-900'
: 'border-red-200 dark:border-red-900'
@@ -105,16 +99,19 @@ export function FeedbackList({
</span>
{item.botName && (
<>
<span className="text-gray-400">→</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
<span className="text-muted-foreground">→</span>
<span className="text-sm text-muted-foreground">
{item.botName}
</span>
</>
)}
{item.platform && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
<Badge
variant="outline"
className="bg-muted text-muted-foreground"
>
{item.platform}
</span>
</Badge>
)}
{item.streamId && onViewMessage && (
<Button
@@ -133,7 +130,7 @@ export function FeedbackList({
</div>
{item.feedbackContent && (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
<p className="text-sm text-muted-foreground line-clamp-2">
{item.feedbackContent}
</p>
)}
@@ -142,12 +139,13 @@ export function FeedbackList({
item.inaccurateReasons.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{item.inaccurateReasons.map((reason, idx) => (
<span
<Badge
key={idx}
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
variant="outline"
className="bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
>
{reason}
</span>
</Badge>
))}
</div>
)}
@@ -156,7 +154,7 @@ export function FeedbackList({
{/* Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{item.timestamp.toLocaleString()}
</span>
</div>
@@ -166,75 +164,75 @@ export function FeedbackList({
{/* Expanded Details */}
{expandedId === item.id && (
<div
className={`border-t p-5 bg-white dark:bg-gray-900 ${
className={`border-t p-5 bg-background ${
item.feedbackType === 'like'
? 'border-green-200 dark:border-green-900'
: 'border-red-200 dark:border-red-900'
}`}
>
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
{/* Context Info */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.feedback.contextInfo')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
{item.botName && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.messageList.bot')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="font-medium text-foreground truncate">
{item.botName}
</div>
</div>
)}
{item.pipelineName && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.messageList.pipeline')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="font-medium text-foreground truncate">
{item.pipelineName}
</div>
</div>
)}
{item.sessionId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.sessions.sessionId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="font-medium text-foreground truncate">
{item.sessionId}
</div>
</div>
)}
{item.userId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.feedback.userId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="font-medium text-foreground truncate">
{item.userId}
</div>
</div>
)}
{item.messageId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.feedback.messageId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="font-medium text-foreground truncate">
{item.messageId}
</div>
</div>
)}
{item.streamId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
<div className="bg-background rounded p-2">
<div className="text-muted-foreground">
{t('monitoring.feedback.streamId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="font-medium text-foreground truncate">
{item.streamId}
</div>
</div>
@@ -244,11 +242,11 @@ export function FeedbackList({
{/* Feedback Content */}
{item.feedbackContent && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.feedback.feedbackContent')}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{item.feedbackContent}
</p>
</div>
@@ -256,7 +254,7 @@ export function FeedbackList({
</div>
</div>
)}
</div>
</Card>
))}
</div>
);
@@ -8,6 +8,7 @@ import {
Voice,
Quote,
} from '@/app/infra/entities/message';
import { Badge } from '@/components/ui/badge';
import ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';
interface MessageContentRendererProps {
@@ -48,23 +49,23 @@ export function MessageContentRenderer({
const displayName =
atComponent.display || atComponent.target?.toString() || '';
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
className="mx-0.5 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@{displayName}
</span>
</Badge>
);
}
case 'AtAll':
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
className="mx-0.5 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@All
</span>
</Badge>
);
case 'Image': {
@@ -73,12 +74,12 @@ export function MessageContentRenderer({
if (!imageUrl) {
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm"
>
[Image]
</span>
</Badge>
);
}
@@ -101,41 +102,41 @@ export function MessageContentRenderer({
case 'File': {
const file = component as MessageChainComponent & { name?: string };
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm [&>svg]:size-3.5"
>
<Paperclip className="w-3.5 h-3.5 mr-1" />
<Paperclip className="mr-1" />
{file.name || 'File'}
</span>
</Badge>
);
}
case 'Voice': {
const voice = component as Voice;
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm [&>svg]:size-3.5"
>
<AudioLines className="w-3.5 h-3.5 mr-1" />
<AudioLines className="mr-1" />
Voice{voice.length ? ` ${voice.length}s` : ''}
</span>
</Badge>
);
}
case 'Quote': {
const quote = component as Quote;
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-l-muted-foreground/50"
>
{quote.origin
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
.map((c) => (c as MessageChainComponent as Plain).text)
.join('') || '[Quote]'}
</span>
</Badge>
);
}
@@ -144,12 +145,12 @@ export function MessageContentRenderer({
default:
return (
<span
<Badge
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm"
>
[{component.type}]
</span>
</Badge>
);
}
};
@@ -1,6 +1,9 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Info, Clock, AlertCircle, Braces } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { MessageDetails } from '../types/monitoring';
interface MessageDetailsCardProps {
@@ -24,7 +27,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
{/* Context Info Section */}
{details.message && (
<div className="bg-muted rounded-lg p-3">
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Info className="w-4 h-4 mr-2" />
{t('monitoring.messageList.viewDetails')}
@@ -79,12 +82,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</div>
</div>
</div>
</div>
</Card>
)}
{/* LLM Calls Section */}
{details.llmCalls && details.llmCalls.length > 0 && (
<div className="bg-muted rounded-lg p-3">
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Clock className="w-4 h-4 mr-2" />
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
@@ -127,15 +130,16 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
<span className="font-medium text-foreground">
#{index + 1} {call.modelName}
</span>
<span
className={`ml-2 text-xs px-2 py-0.5 rounded ${
<Badge
className={cn(
'ml-2',
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
)}
>
{call.status}
</span>
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{call.duration}ms
@@ -163,12 +167,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</div>
))}
</div>
</div>
</Card>
)}
{/* Errors Section */}
{details.errors && details.errors.length > 0 && (
<div className="bg-muted rounded-lg p-3">
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
<AlertCircle className="w-4 h-4 mr-2" />
{t('monitoring.errors.title')} ({details.errors.length})
@@ -198,12 +202,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</div>
))}
</div>
</div>
</Card>
)}
{/* Query Variables Section */}
{queryVariables && Object.keys(queryVariables).length > 0 && (
<div className="bg-muted rounded-lg p-3">
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Braces className="w-4 h-4 mr-2" />
{t('monitoring.queryVariables.title')}
@@ -233,7 +237,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</div>
))}
</div>
</div>
</Card>
)}
{/* No data message */}
@@ -0,0 +1,50 @@
'use client';
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
interface TabStateProps {
/** Skeleton rows while the tab's data is in flight. */
loading?: boolean;
/** Number of skeleton rows; the rhythm matches the loaded content. */
rows?: number;
/** Icon for the empty state, in the same muted tone as the copy. */
icon?: React.ReactNode;
/** Empty-state headline. */
title?: string;
/** Optional second line explaining what would fill the tab. */
hint?: string;
}
/**
* The one loading/empty treatment every detail tab uses.
*
* Tabs are peers on one fixed-height surface, so they must not each invent their
* own placeholder shape: a reader switching tabs sees the same skeleton rhythm
* and the same centred empty card regardless of which record type is empty.
*/
export function TabState({
loading,
rows = 6,
icon,
title,
hint,
}: TabStateProps) {
if (loading) {
return (
<div className="space-y-3" role="status" aria-busy="true">
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className="h-14 w-full rounded-xl" />
))}
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
{icon}
{title && <div className="text-sm">{title}</div>}
{hint && <div className="text-xs">{hint}</div>}
</div>
);
}
@@ -19,6 +19,19 @@ import {
AlertTriangle,
TrendingUp,
} from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card } from '@/components/ui/card';
import { MetricCard } from './overview-cards/MetricCard';
import { Alert, AlertTitle } from '@/components/ui/alert';
import { TabState } from './TabState';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getErrorMessage } from '../utils';
@@ -89,41 +102,6 @@ const TOOLTIP_STYLE: React.CSSProperties = {
color: 'var(--foreground)',
};
function MetricTile({
icon,
label,
value,
sub,
accent,
}: {
icon: React.ReactNode;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<div className="bg-card rounded-xl border p-4 flex flex-col gap-2">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<span
className="flex items-center justify-center h-7 w-7 rounded-lg"
style={{
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
color: accent || 'var(--foreground)',
}}
>
{icon}
</span>
{label}
</div>
<div className="text-2xl font-semibold text-foreground tabular-nums">
{value}
</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
export default function TokenMonitoring({
botIds,
pipelineIds,
@@ -175,38 +153,23 @@ export default function TokenMonitoring({
}, [stats]);
if (loading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border p-4 h-24 animate-pulse"
/>
))}
</div>
<div className="bg-card rounded-xl border p-6 h-[320px] animate-pulse" />
</div>
);
return <TabState loading rows={6} />;
}
if (error) {
return (
<div className="bg-card rounded-xl border p-6 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
{t('monitoring.tokens.loadError', { error })}
</div>
<Alert variant="destructive">
<AlertTitle>{t('monitoring.tokens.loadError', { error })}</AlertTitle>
</Alert>
);
}
if (!stats || !stats.summary || stats.summary.total_calls === 0) {
return (
<div className="bg-card rounded-xl border p-6">
<div className="h-[260px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<Coins className="h-[3rem] w-[3rem]" />
<div className="text-sm">{t('monitoring.tokens.noData')}</div>
</div>
</div>
<TabState
icon={<Coins className="h-12 w-12" />}
title={t('monitoring.tokens.noData')}
/>
);
}
@@ -229,70 +192,69 @@ export default function TokenMonitoring({
{/* Summary tiles */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<MetricTile
<MetricCard
icon={<Coins className="h-4 w-4" />}
label={t('monitoring.tokens.totalTokens')}
value={formatNumber(summary.total_tokens)}
sub={t('monitoring.tokens.acrossCalls', {
hint={t('monitoring.tokens.acrossCalls', {
count: summary.total_calls,
})}
accent="#8b5cf6"
/>
<MetricTile
<MetricCard
icon={<ArrowDownToLine className="h-4 w-4" />}
label={t('monitoring.tokens.inputTokens')}
value={formatNumber(summary.total_input_tokens)}
accent="#3b82f6"
/>
<MetricTile
<MetricCard
icon={<ArrowUpFromLine className="h-4 w-4" />}
label={t('monitoring.tokens.outputTokens')}
value={formatNumber(summary.total_output_tokens)}
accent="#10b981"
/>
<MetricTile
<MetricCard
icon={<TrendingUp className="h-4 w-4" />}
label={t('monitoring.tokens.avgPerCall')}
value={formatNumber(summary.avg_tokens_per_call)}
accent="#f59e0b"
/>
<MetricTile
<MetricCard
icon={<Gauge className="h-4 w-4" />}
label={t('monitoring.tokens.throughput')}
value={`${summary.avg_tokens_per_second}`}
sub={t('monitoring.tokens.tokensPerSec')}
hint={t('monitoring.tokens.tokensPerSec')}
accent="#06b6d4"
/>
<MetricTile
<MetricCard
icon={<AlertTriangle className="h-4 w-4" />}
label={t('monitoring.tokens.errorCalls')}
value={`${summary.error_calls}`}
sub={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
hint={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
accent="#ef4444"
/>
</div>
{/* Token usage over time */}
<div className="bg-card rounded-xl border p-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-base font-semibold text-foreground">
{t('monitoring.tokens.usageOverTime')}
</h3>
<div className="inline-flex rounded-lg border p-0.5 text-sm">
{(['hour', 'day'] as const).map((b) => (
<button
key={b}
onClick={() => setBucket(b)}
className={`px-3 py-1 rounded-md transition-colors ${
bucket === b
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`monitoring.tokens.bucket.${b}`)}
</button>
))}
</div>
<Tabs
value={bucket}
onValueChange={(value) => {
if (value === 'hour' || value === 'day') setBucket(value);
}}
>
<TabsList className="h-8">
{(['hour', 'day'] as const).map((b) => (
<TabsTrigger key={b} value={b} className="text-xs">
{t(`monitoring.tokens.bucket.${b}`)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
@@ -372,93 +334,86 @@ export default function TokenMonitoring({
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</Card>
{/* Per-model breakdown */}
<div className="bg-card rounded-xl border p-6">
<Card className="p-6">
<h3 className="text-base font-semibold text-foreground mb-4">
{t('monitoring.tokens.byModel')}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground border-b">
<th className="py-2 pr-4 font-medium">
{t('monitoring.tokens.model')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.calls')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.inputTokens')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.outputTokens')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.totalTokens')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.avgPerCall')}
</th>
<th className="py-2 pl-4 font-medium text-right">
{t('monitoring.tokens.avgLatency')}
</th>
</tr>
</thead>
<tbody>
{by_model.map((m) => {
const share =
summary.total_tokens > 0
? (m.total_tokens / summary.total_tokens) * 100
: 0;
return (
<tr
key={m.model_name}
className="border-b last:border-0 hover:bg-muted/40 transition-colors"
>
<td className="py-2.5 pr-4">
<div className="font-medium text-foreground">
{m.model_name}
</div>
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-violet-500"
style={{ width: `${share}%` }}
/>
</div>
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{m.calls}
{m.error_calls > 0 && (
<span className="text-destructive">
{' '}
({m.error_calls}✕)
</span>
)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatNumber(m.input_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatNumber(m.output_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums font-medium">
{formatNumber(m.total_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatNumber(m.avg_tokens_per_call)}
</td>
<td className="py-2.5 pl-4 text-right tabular-nums">
{m.avg_duration_ms}ms
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('monitoring.tokens.model')}</TableHead>
<TableHead className="text-right">
{t('monitoring.tokens.calls')}
</TableHead>
<TableHead className="text-right">
{t('monitoring.tokens.inputTokens')}
</TableHead>
<TableHead className="text-right">
{t('monitoring.tokens.outputTokens')}
</TableHead>
<TableHead className="text-right">
{t('monitoring.tokens.totalTokens')}
</TableHead>
<TableHead className="text-right">
{t('monitoring.tokens.avgPerCall')}
</TableHead>
<TableHead className="text-right">
{t('monitoring.tokens.avgLatency')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{by_model.map((m) => {
const share =
summary.total_tokens > 0
? (m.total_tokens / summary.total_tokens) * 100
: 0;
return (
<TableRow key={m.model_name}>
<TableCell>
<div className="font-medium text-foreground">
{m.model_name}
</div>
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-violet-500"
style={{ width: `${share}%` }}
/>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{m.calls}
{m.error_calls > 0 && (
<span className="text-destructive">
{' '}
({m.error_calls}✕)
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(m.input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(m.output_tokens)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatNumber(m.total_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(m.avg_tokens_per_call)}
</TableCell>
<TableCell className="text-right tabular-nums">
{m.avg_duration_ms}ms
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
</div>
);
}
@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ExternalLink } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { backendClient } from '@/app/infra/http';
import type {
ExecutionCallRow,
ExecutionDetail,
ExecutionRow,
} from '@/app/infra/entities/api/monitoring-executions';
import AgentExecutionTrace from '@/app/home/agents/components/AgentExecutionTrace';
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
import { formatDateTime } from '../../utils/dateUtils';
import {
executionKindLabel,
executionProcessorLabel,
executionStatusLabel,
executionStatusVariant,
executionTargetLabel,
} from './ExecutionTable';
interface ExecutionDetailSheetProps {
row: ExecutionRow | null;
onClose: () => void;
}
/** Walk parsed JSON, collecting every `"text"` value (OpenAI-style content). */
function collectText(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map(collectText).filter(Boolean).join(' ');
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.text === 'string') return record.text;
return Object.values(record).map(collectText).filter(Boolean).join(' ');
}
return '';
}
function messagePreview(raw: string | null | undefined): string {
if (!raw) return '';
try {
return collectText(JSON.parse(raw)).trim() || raw;
} catch {
return raw;
}
}
function callTitle(call: ExecutionCallRow, index: number): string {
const candidates = [
call.model_name,
call.model,
call.name,
call.tool_name,
call.function,
call.error_type,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate) return candidate;
}
return typeof call.id === 'string' && call.id ? call.id : `#${index + 1}`;
}
export default function ExecutionDetailSheet({
row,
onClose,
}: ExecutionDetailSheetProps) {
const { t } = useTranslation();
const [detail, setDetail] = useState<ExecutionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const requestRef = useRef(0);
useEffect(() => {
if (!row) {
setDetail(null);
setError(false);
setLoading(false);
return;
}
const requestId = requestRef.current + 1;
requestRef.current = requestId;
setLoading(true);
setError(false);
setDetail(null);
backendClient
.getExecutionDetail(row.source, row.id)
.then((result) => {
if (requestRef.current !== requestId) return;
setDetail(result);
})
.catch(() => {
if (requestRef.current !== requestId) return;
setError(true);
})
.finally(() => {
if (requestRef.current === requestId) setLoading(false);
});
}, [row]);
const isAgent =
detail != null ? detail.source === 'agent' : row?.source === 'agent';
const metadata: { label: string; value: string }[] = [];
if (row) {
metadata.push({
label: t('monitoring.execution.detail.executionId'),
value: row.id,
});
if (row.event_id) {
metadata.push({
label: t('monitoring.execution.detail.eventId'),
value: row.event_id,
});
}
if (row.runner_id) {
metadata.push({
label: t('monitoring.execution.detail.runner'),
value: row.runner_id,
});
}
if (row.queue_name) {
metadata.push({
label: t('monitoring.execution.detail.queue'),
value: row.queue_name,
});
}
if (row.created_at_ms != null) {
metadata.push({
label: t('monitoring.execution.detail.created'),
value: formatDateTime(new Date(row.created_at_ms)),
});
}
if (row.duration_ms != null) {
metadata.push({
label: t('monitoring.execution.detail.duration'),
value: formatRunDuration(row.duration_ms),
});
}
if (isAgent && row.usage?.total_tokens != null) {
metadata.push({
label: t('monitoring.execution.detail.usage'),
value: row.usage.total_tokens.toLocaleString(),
});
}
}
const objectHref = row
? row.target_kind === 'agent' && row.target_id
? `/home/agents?id=${encodeURIComponent(row.target_id)}`
: row.target_kind === 'pipeline' && row.target_id
? `/home/pipelines?id=${encodeURIComponent(row.target_id)}`
: null
: null;
const renderCalls = (title: string, calls: ExecutionCallRow[]) => (
<div className="space-y-2">
<h3 className="text-sm font-medium">{title}</h3>
{calls.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('monitoring.execution.detail.noEvents')}
</p>
) : (
<div className="space-y-1">
{calls.map((call, index) => {
const duration =
typeof call.duration === 'number' ? call.duration : null;
return (
<div
key={`${title}-${call.id}-${index}`}
className="flex items-start justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{callTitle(call, index)}</div>
{call.error_message && (
<div className="truncate text-xs text-muted-foreground">
{call.error_message}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{call.status && (
<Badge
variant={executionStatusVariant(
String(call.status).toLowerCase(),
)}
>
{call.status}
</Badge>
)}
<span className="w-16 text-right tabular-nums text-muted-foreground">
{duration == null ? '—' : formatRunDuration(duration)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
return (
<Sheet
open={row != null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<SheetContent side="right" className="w-full p-0 sm:max-w-2xl">
<SheetDescription className="sr-only">
{t('monitoring.execution.detail.a11yDescription')}
</SheetDescription>
<ScrollArea className="h-full">
<div className="space-y-6 p-6">
{row && (
<>
<SheetHeader className="space-y-3">
<SheetTitle className="pr-8 text-left">
{executionProcessorLabel(row)}
</SheetTitle>
<p className="text-sm text-muted-foreground">
{t('monitoring.execution.triggeredBy', {
trigger: executionTargetLabel(row, t),
})}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">
{executionKindLabel(row, t)}
</Badge>
<Badge variant={executionStatusVariant(row.status_group)}>
{executionStatusLabel(row, t)}
</Badge>
{row.debug && (
<Badge variant="secondary">
{t('monitoring.execution.debug')}
</Badge>
)}
{objectHref && (
<Button
asChild
size="sm"
variant="outline"
className="ml-auto h-7"
>
<Link to={objectHref}>
<ExternalLink className="mr-1 h-3.5 w-3.5" />
{t('monitoring.execution.detail.openObject')}
</Link>
</Button>
)}
</div>
</SheetHeader>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{metadata.map((item) => (
<div key={item.label} className="min-w-0 space-y-0.5">
<div className="text-xs text-muted-foreground">
{item.label}
</div>
<div className="truncate text-sm font-medium">
{item.value}
</div>
</div>
))}
</div>
<Separator />
{isAgent ? (
<div className="space-y-3">
<h3 className="text-sm font-medium">
{t('monitoring.execution.detail.timeline')}
</h3>
{loading && (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
)}
{!loading && error && (
<p className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{!loading &&
!error &&
detail?.source === 'agent' &&
(detail.events.length > 0 ? (
<AgentExecutionTrace
events={detail.events}
finished={row.status_group !== 'running'}
toolLabels={{}}
/>
) : (
<p className="text-sm text-muted-foreground">
{t('monitoring.execution.detail.noEvents')}
</p>
))}
</div>
) : (
<div className="space-y-5">
{loading && (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
)}
{!loading && error && (
<p className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{!loading && !error && detail?.source === 'pipeline' && (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t('monitoring.execution.detail.query')}
</h3>
<p className="whitespace-pre-wrap break-words rounded-md border bg-muted/40 px-3 py-2 text-sm">
{messagePreview(detail.message.message_content) ||
'—'}
</p>
</div>
{renderCalls(
t('monitoring.execution.detail.modelCalls'),
detail.llm_calls,
)}
{renderCalls(
t('monitoring.execution.detail.toolCalls'),
detail.tool_calls,
)}
{renderCalls(
t('monitoring.execution.detail.errors'),
detail.errors,
)}
</>
)}
</div>
)}
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,169 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
import { formatDateTime } from '../../utils/dateUtils';
interface ExecutionTableProps {
rows: ExecutionRow[];
selectedId?: string | null;
onSelect: (row: ExecutionRow) => void;
}
const STATUS_GROUPS = [
'completed',
'failed',
'running',
'queued',
'cancelled',
'ignored',
];
export function executionStatusLabel(row: ExecutionRow, t: TFunction): string {
if (STATUS_GROUPS.includes(row.status_group)) {
return t(`monitoring.execution.status.${row.status_group}`);
}
return row.status;
}
export function executionStatusVariant(
statusGroup: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (statusGroup === 'failed') return 'destructive';
if (statusGroup === 'ignored' || statusGroup === 'queued') return 'secondary';
return 'outline';
}
const KIND_GROUPS = ['agent', 'pipeline', 'processor'];
export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
if (KIND_GROUPS.includes(row.target_kind)) {
return t(`monitoring.execution.kind.${row.target_kind}`);
}
return row.target_kind;
}
export function executionTargetLabel(row: ExecutionRow, t: TFunction): string {
if (row.target_kind === 'agent') {
return eventPatternLabel(row.title, t);
}
const title = row.title ?? '';
return title.length > 48 ? `${title.slice(0, 48)}…` : title;
}
// The processor the row ran: the Agent or Pipeline that handled it. A run whose
// Agent was deleted still names the Runner that executed it.
export function executionProcessorLabel(row: ExecutionRow): string {
return row.target_name || row.runner_id || row.target_id || '—';
}
export default function ExecutionTable({
rows,
selectedId,
onSelect,
}: ExecutionTableProps) {
const { t } = useTranslation();
return (
// The page hands this table a fixed-height region: the table's own scroll
// container takes that height so the header stays sticky while rows scroll
// under it.
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border">
<Table containerClassName="min-h-0 flex-1 overflow-y-auto">
<TableHeader className="bg-card sticky top-0 z-10">
<TableRow>
<TableHead>{t('monitoring.execution.columns.time')}</TableHead>
<TableHead>{t('monitoring.execution.columns.target')}</TableHead>
<TableHead>{t('monitoring.execution.columns.status')}</TableHead>
<TableHead className="text-right">
{t('monitoring.execution.columns.duration')}
</TableHead>
<TableHead className="text-right">
{t('monitoring.execution.columns.tokens')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const selected = selectedId === row.id;
return (
<TableRow
key={`${row.source}-${row.id}`}
role="button"
tabIndex={0}
aria-pressed={selected}
data-state={selected ? 'selected' : undefined}
className="cursor-pointer data-[state=selected]:bg-muted"
onClick={() => onSelect(row)}
onKeyDown={(
event: React.KeyboardEvent<HTMLTableRowElement>,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(row);
}
}}
>
<TableCell className="whitespace-nowrap text-muted-foreground">
{row.created_at_ms == null
? '—'
: formatDateTime(new Date(row.created_at_ms))}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge variant="outline">
{executionKindLabel(row, t)}
</Badge>
<span className="truncate font-medium">
{executionProcessorLabel(row)}
</span>
</div>
<span className="truncate text-xs text-muted-foreground">
{t('monitoring.execution.triggeredBy', {
trigger: executionTargetLabel(row, t),
})}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={executionStatusVariant(row.status_group)}>
{executionStatusLabel(row, t)}
</Badge>
{row.debug && (
<Badge variant="secondary">
{t('monitoring.execution.debug')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{row.duration_ms == null
? '—'
: formatRunDuration(row.duration_ms)}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.usage?.total_tokens != null
? row.usage.total_tokens.toLocaleString()
: '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -7,8 +7,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { backendClient } from '@/app/infra/http';
import { TimeRangeOption } from '../../types/monitoring';
import type {
ExecutionModeFilter,
ExecutionSourceFilter,
} from '@/app/infra/entities/api/monitoring-executions';
interface MonitoringFiltersProps {
selectedBots: string[];
@@ -17,8 +22,23 @@ interface MonitoringFiltersProps {
onBotsChange: (bots: string[]) => void;
onPipelinesChange: (pipelines: string[]) => void;
onTimeRangeChange: (timeRange: TimeRangeOption) => void;
source?: ExecutionSourceFilter;
onSourceChange?: (value: ExecutionSourceFilter) => void;
mode?: ExecutionModeFilter;
onModeChange?: (value: ExecutionModeFilter) => void;
statusGroup?: string;
onStatusGroupChange?: (value: string) => void;
}
const STATUS_GROUP_OPTIONS = [
'completed',
'failed',
'running',
'queued',
'cancelled',
'ignored',
];
interface Bot {
uuid: string;
name: string;
@@ -36,6 +56,12 @@ export default function MonitoringFilters({
onBotsChange,
onPipelinesChange,
onTimeRangeChange,
source,
onSourceChange,
mode,
onModeChange,
statusGroup,
onStatusGroupChange,
}: MonitoringFiltersProps) {
const { t } = useTranslation();
const [bots, setBots] = useState<Bot[]>([]);
@@ -110,11 +136,98 @@ export default function MonitoringFilters({
return (
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-6">
{/* Source Filter */}
{onSourceChange && (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.execution.filters.source')}
</Label>
<Select
value={source ?? 'all'}
onValueChange={(value) =>
onSourceChange(value as ExecutionSourceFilter)
}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.execution.filters.sourceAll')}
</SelectItem>
<SelectItem value="agent">
{t('monitoring.execution.filters.sourceAgent')}
</SelectItem>
<SelectItem value="pipeline">
{t('monitoring.execution.filters.sourcePipeline')}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Mode Filter */}
{onModeChange && (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.execution.filters.mode')}
</Label>
<Select
value={mode ?? 'all'}
onValueChange={(value) =>
onModeChange(value as ExecutionModeFilter)
}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.execution.filters.modeAll')}
</SelectItem>
<SelectItem value="real">
{t('monitoring.execution.filters.modeLive')}
</SelectItem>
<SelectItem value="debug">
{t('monitoring.execution.filters.modeDebug')}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Status Filter */}
{onStatusGroupChange && (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.execution.filters.status')}
</Label>
<Select
value={statusGroup ?? 'all'}
onValueChange={(value) => onStatusGroupChange(value)}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.execution.filters.statusAll')}
</SelectItem>
{STATUS_GROUP_OPTIONS.map((group) => (
<SelectItem key={group} value={group}>
{t(`monitoring.execution.status.${group}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Bot Filter */}
<div className="flex items-center gap-2">
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.bot')}
</label>
</Label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
@@ -144,9 +257,9 @@ export default function MonitoringFilters({
{/* Pipeline Filter */}
<div className="flex items-center gap-2">
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.pipeline')}
</label>
</Label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
@@ -176,9 +289,9 @@ export default function MonitoringFilters({
{/* Time Range Filter */}
<div className="flex items-center gap-2">
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.timeRange')}
</label>
</Label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="h-9 w-full sm:w-[150px]">
<SelectValue />
@@ -0,0 +1,197 @@
import { useTranslation } from 'react-i18next';
import {
Activity,
CheckCircle2,
Info,
Timer,
TriangleAlert,
Users,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { ExecutionSummary } from '@/app/infra/entities/api/monitoring-executions';
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
import { MetricCard } from './MetricCard';
interface ExecutionOverviewCardsProps {
summary: ExecutionSummary | null;
loading?: boolean;
/** Active bot conversation sessions from the message rollup endpoint. */
activeSessions?: number | null;
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1">
<div className="text-2xl font-bold tabular-nums text-foreground">
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
);
}
export default function ExecutionOverviewCards({
summary,
loading,
activeSessions,
}: ExecutionOverviewCardsProps) {
const { t } = useTranslation();
const overview = summary?.executions ?? null;
const tokens = summary?.tokens ?? null;
// Bare cards, not a grid: the page owns the grid so every metric and the
// runtime status card land in the same rows.
return (
<>
<MetricCard
label={t('monitoring.execution.cards.totalExecutions')}
icon={<Activity className="h-4 w-4" />}
accent="#8b5cf6"
loading={loading}
value={(overview?.total ?? 0).toLocaleString()}
hint={t('monitoring.execution.cards.bySource', {
agent: (overview?.by_source.agent ?? 0).toLocaleString(),
pipeline: (overview?.by_source.pipeline ?? 0).toLocaleString(),
})}
>
{!!overview?.debug && (
<div className="text-xs text-muted-foreground">
{t('monitoring.execution.cards.hiddenDebug', {
count: overview.debug,
})}
</div>
)}
</MetricCard>
<MetricCard
label={t('monitoring.execution.cards.successRate')}
icon={<CheckCircle2 className="h-4 w-4" />}
accent="#3b82f6"
loading={loading}
value={
overview?.success_rate != null ? `${overview.success_rate}%` : '—'
}
hint={t('monitoring.execution.cards.denominator', {
completed: (overview?.completed ?? 0).toLocaleString(),
denominator: (overview?.denominator ?? 0).toLocaleString(),
})}
action={
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label={t('monitoring.execution.cards.successRate')}
>
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{t('monitoring.execution.cards.successRateHint')}
</TooltipContent>
</Tooltip>
}
/>
<MetricCard
label={t('monitoring.execution.cards.running')}
icon={<Activity className="h-4 w-4" />}
accent="#06b6d4"
loading={loading}
>
<div className="grid grid-cols-3 gap-3">
<Stat
label={t('monitoring.execution.cards.running')}
value={(overview?.running ?? 0).toLocaleString()}
/>
<Stat
label={t('monitoring.execution.cards.queued')}
value={(overview?.queued ?? 0).toLocaleString()}
/>
<Stat
label={t('monitoring.execution.cards.waiting')}
value={(overview?.waiting ?? 0).toLocaleString()}
/>
</div>
</MetricCard>
<MetricCard
label={t('monitoring.execution.cards.failed')}
icon={<TriangleAlert className="h-4 w-4" />}
accent="#ef4444"
loading={loading}
value={
<span className="flex items-center gap-3">
{(overview?.failed ?? 0).toLocaleString()}
<Badge variant="secondary">
{t('monitoring.execution.cards.ignored')}{' '}
{(overview?.ignored ?? 0).toLocaleString()}
</Badge>
</span>
}
/>
<MetricCard
label={t('monitoring.execution.cards.latency')}
icon={<Timer className="h-4 w-4" />}
accent="#f59e0b"
loading={loading}
>
<div className="grid grid-cols-2 gap-3">
<Stat
label={t('monitoring.execution.cards.p50')}
value={
overview?.p50_duration_ms != null
? formatRunDuration(overview.p50_duration_ms)
: '—'
}
/>
<Stat
label={t('monitoring.execution.cards.p95')}
value={
overview?.p95_duration_ms != null
? formatRunDuration(overview.p95_duration_ms)
: '—'
}
/>
</div>
{/* The sample size explains the percentiles above, so it stays under
them rather than in the card's hint slot. */}
<div className="text-xs text-muted-foreground">
{t('monitoring.execution.cards.sample', {
count: overview?.duration_sample ?? 0,
})}
</div>
</MetricCard>
<MetricCard
label={t('monitoring.execution.cards.tokens')}
icon={<Zap className="h-4 w-4" />}
accent="#10b981"
loading={loading}
value={(tokens?.total_tokens ?? 0).toLocaleString()}
hint={t('monitoring.execution.cards.coverage', {
withUsage: (tokens?.calls_with_usage ?? 0).toLocaleString(),
calls: (tokens?.calls ?? 0).toLocaleString(),
})}
/>
<MetricCard
label={t('monitoring.activeSessions')}
icon={<Users className="h-4 w-4" />}
accent="#6366f1"
loading={loading}
value={(activeSessions ?? 0).toLocaleString()}
/>
</>
);
}
@@ -1,80 +1,88 @@
import React from 'react';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
'use client';
interface MetricCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
trend?: {
value: number;
direction: 'up' | 'down';
};
import React from 'react';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
export interface MetricCardProps {
/** Small label, sitting next to the icon. */
label: string;
/** The card's headline value; a node so it can carry a badge. */
value?: React.ReactNode;
/** Icon of the tinted tile on the leading edge. */
icon?: React.ReactNode;
/** Muted line under the value. */
hint?: React.ReactNode;
/** Tile colour as a hex value; defaults to the muted token. */
accent?: string;
/** Rendered at the card's top-right (tooltip trigger, help dialog, badge). */
action?: React.ReactNode;
/** Extra body under the value, for composite cards (stat grids, status rows). */
children?: React.ReactNode;
loading?: boolean;
className?: string;
}
export default function MetricCard({
title,
/**
* The dashboard's one metric card: tinted icon tile and label on the leading
* edge, value, then a muted hint. Token, execution, feedback and runtime
* counters all render through it, so their rows and internal spacing match
* instead of each surface inventing its own card.
*/
export function MetricCard({
label,
value,
icon,
trend,
hint,
accent,
action,
children,
loading,
className,
}: MetricCardProps) {
if (loading) {
return (
<Card className="transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">
{icon}
</div>
</div>
</CardHeader>
<CardContent>
<div className="h-9 w-28 bg-muted animate-pulse rounded"></div>
<div className="h-4 w-20 bg-muted animate-pulse rounded mt-2"></div>
</CardContent>
</Card>
);
}
return (
<Card className="transition-all duration-300 group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">{icon}</div>
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-foreground mb-2">{value}</div>
{trend && (
<div className="flex items-center gap-1.5">
<Card className={cn('gap-3 p-4', className)}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{icon ? (
<span
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${
trend.direction === 'up'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
color: accent || 'var(--muted-foreground)',
}}
>
{trend.direction === 'up' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{Math.abs(trend.value)}%
{icon}
</span>
<span className="text-xs text-muted-foreground">
vs previous period
</span>
</div>
)}
</CardContent>
) : null}
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
</div>
{action}
</div>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-3 w-20" />
</div>
) : (
<>
{value != null && value !== '' && (
<div className="text-2xl font-semibold tabular-nums text-foreground">
{value}
</div>
)}
{hint != null && hint !== '' && (
<div className="text-xs text-muted-foreground">{hint}</div>
)}
{children}
</>
)}
</Card>
);
}
export default MetricCard;
@@ -1,100 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
import MetricCard from './MetricCard';
import SystemStatusCard from './SystemStatusCards';
import TrafficChart from './TrafficChart';
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
interface OverviewCardsProps {
metrics: OverviewMetrics | null;
traffic?: MonitoringData['traffic'];
loading?: boolean;
refreshKey?: number;
}
export default function OverviewCards({
metrics,
traffic,
loading,
refreshKey,
}: OverviewCardsProps) {
const { t } = useTranslation();
const cards = [
{
title: t('monitoring.totalMessages'),
value: metrics?.totalMessages || 0,
icon: <MessageSquare />,
trend: metrics?.trends
? {
value: metrics.trends.messages,
direction: (metrics.trends.messages >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.modelCallsCount'),
value: metrics?.modelCalls || 0,
icon: <Sparkles />,
trend: metrics?.trends
? {
value: metrics.trends.llmCalls,
direction: (metrics.trends.llmCalls >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.successRate'),
value: metrics ? `${metrics.successRate}%` : '0%',
icon: <Check />,
trend: metrics?.trends
? {
value: metrics.trends.successRate,
direction: (metrics.trends.successRate >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.activeSessions'),
value: metrics?.activeSessions || 0,
icon: <Users />,
trend: metrics?.trends
? {
value: metrics.trends.sessions,
direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
];
return (
<div className="space-y-6">
{/* Metric Cards + System Status */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-6">
{cards.map((card, index) => (
<MetricCard
key={index}
title={card.title}
value={card.value}
icon={card.icon}
trend={card.trend}
loading={loading}
/>
))}
<SystemStatusCard refreshKey={refreshKey} />
</div>
{/* Traffic Chart */}
<TrafficChart traffic={traffic} loading={loading} />
</div>
);
}
@@ -5,7 +5,7 @@ import {
Box,
CircleCheck,
CircleX,
Loader2,
Server,
Info,
Container,
Clock,
@@ -15,7 +15,7 @@ import {
Image,
FolderOpen,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { MetricCard } from './MetricCard';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -38,6 +38,10 @@ import { httpClient } from '@/app/infra/http/HttpClient';
type StatusState = 'ok' | 'disabled' | 'failed' | null;
// Neutral tile: the status dots already carry the health colour, so the card's
// own tile must not encode a second, conflicting verdict.
const SYSTEM_STATUS_ACCENT = '#64748b';
function StatusDot({ state }: { state: StatusState }) {
if (state === null)
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
@@ -118,28 +122,22 @@ export default function SystemStatusCard({
if (loading) {
return (
<Card className="transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('monitoring.systemStatus')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
<MetricCard
label={t('monitoring.systemStatus')}
icon={<Server className="h-4 w-4" />}
accent={SYSTEM_STATUS_ACCENT}
loading
/>
);
}
return (
<>
<Card className="transition-all duration-300 group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('monitoring.systemStatus')}
</CardTitle>
<MetricCard
label={t('monitoring.systemStatus')}
icon={<Server className="h-4 w-4" />}
accent={SYSTEM_STATUS_ACCENT}
action={
<Button
variant="ghost"
size="icon"
@@ -148,8 +146,9 @@ export default function SystemStatusCard({
>
<Info className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2">
}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<StatusDot state={pluginState} />
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
@@ -162,8 +161,8 @@ export default function SystemStatusCard({
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
</div>
)}
</CardContent>
</Card>
</div>
</MetricCard>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
@@ -11,6 +11,8 @@ import {
ResponsiveContainer,
Legend,
} from 'recharts';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { MonitoringData } from '../../types/monitoring';
interface TrafficChartProps {
@@ -41,24 +43,24 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
if (loading) {
return (
<div className="bg-card rounded-xl border p-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
<Skeleton className="h-5 w-32 rounded" />
<div className="flex gap-4">
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</div>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse w-full h-full bg-muted rounded"></div>
<Skeleton className="w-full h-full rounded" />
</div>
</div>
</Card>
);
}
if (chartData.length === 0) {
return (
<div className="bg-card rounded-xl border p-6">
<Card className="p-6">
<h3 className="text-base font-semibold text-foreground mb-4">
{t('monitoring.trafficChart.title')}
</h3>
@@ -72,12 +74,12 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
)}
</div>
</div>
</div>
</Card>
);
}
return (
<div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
<Card className="p-6 transition-shadow duration-300">
<h3 className="text-base font-semibold text-foreground mb-6">
{t('monitoring.trafficChart.title')}
</h3>
@@ -173,6 +175,6 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { TimeRangeOption, DateRange } from '../types/monitoring';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import { resolveMonitoringWindow } from '../utils/dateUtils';
import type {
ExecutionListResult,
ExecutionModeFilter,
ExecutionSourceFilter,
} from '@/app/infra/entities/api/monitoring-executions';
/**
* Custom hook for fetching the unified execution list
*/
export function useExecutions(params: {
selectedBots: string[];
selectedPipelines: string[];
selectedAgents: string[];
source: ExecutionSourceFilter;
mode: ExecutionModeFilter;
statusGroup: string;
timeRange: TimeRangeOption;
customDateRange: DateRange | null;
limit: number;
offset: number;
refreshKey?: number;
}) {
const [result, setResult] = useState<ExecutionListResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [nonce, setNonce] = useState(0);
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
const requestIdRef = useRef(0);
const scope = JSON.stringify([workspaceUuid, params]);
const [requestScope, setRequestScope] = useState<string | null>(null);
// Memoize filter parameters to prevent unnecessary re-renders
const selectedBotsStr = useMemo(
() => JSON.stringify(params.selectedBots),
[params.selectedBots],
);
const selectedPipelinesStr = useMemo(
() => JSON.stringify(params.selectedPipelines),
[params.selectedPipelines],
);
const selectedAgentsStr = useMemo(
() => JSON.stringify(params.selectedAgents),
[params.selectedAgents],
);
const customDateRangeStr = useMemo(
() => JSON.stringify(params.customDateRange),
[params.customDateRange],
);
// Fetch data based on filters
const fetchData = useCallback(async () => {
const requestId = ++requestIdRef.current;
const isCurrent = () =>
requestId === requestIdRef.current &&
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
setRequestScope(scope);
setResult(null);
setLoading(true);
setError(null);
try {
const { startTime, endTime } = resolveMonitoringWindow(
params.timeRange,
params.customDateRange,
);
const response = await backendClient.getExecutions({
botId: params.selectedBots.length > 0 ? params.selectedBots : undefined,
pipelineId:
params.selectedPipelines.length > 0
? params.selectedPipelines
: undefined,
agentId:
params.selectedAgents.length > 0 ? params.selectedAgents : undefined,
status: params.statusGroup === 'all' ? undefined : [params.statusGroup],
source: params.source,
mode: params.mode,
startTime,
endTime,
limit: params.limit,
offset: params.offset,
});
if (!isCurrent()) return;
setResult(response);
} catch (err) {
if (!isCurrent()) return;
setError(err as Error);
console.error('Failed to fetch executions:', err);
} finally {
if (isCurrent()) setLoading(false);
}
}, [
params.selectedBots,
params.selectedPipelines,
params.selectedAgents,
params.source,
params.mode,
params.statusGroup,
params.timeRange,
params.customDateRange,
params.limit,
params.offset,
scope,
workspaceUuid,
]);
// Fetch data when filter state changes
useEffect(() => {
fetchData();
return () => {
requestIdRef.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedBotsStr,
selectedPipelinesStr,
selectedAgentsStr,
params.source,
params.mode,
params.statusGroup,
params.timeRange,
customDateRangeStr,
params.limit,
params.offset,
params.refreshKey,
nonce,
workspaceUuid,
]);
// Manual refetch function
const refetch = () => {
setNonce((value) => value + 1);
};
return {
result: requestScope === scope ? result : null,
loading: requestScope !== scope || loading,
error: requestScope === scope ? error : null,
refetch,
};
}
@@ -8,7 +8,7 @@ import {
} from '../types/monitoring';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import { parseUTCTimestamp } from '../utils/dateUtils';
import { parseUTCTimestamp, resolveMonitoringWindow } from '../utils/dateUtils';
/**
* Custom hook for fetching and managing monitoring data
@@ -36,45 +36,6 @@ export function useMonitoringData(filterState: FilterState) {
[filterState.customDateRange],
);
// Convert time range to datetime strings
const getTimeRange = useCallback(() => {
const now = new Date();
let startTime: Date | null = null;
switch (filterState.timeRange) {
case 'lastHour':
startTime = new Date(now.getTime() - 60 * 60 * 1000);
break;
case 'last6Hours':
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
break;
case 'last24Hours':
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case 'last7Days':
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'last30Days':
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case 'custom':
if (filterState.customDateRange) {
startTime = filterState.customDateRange.from;
}
break;
}
const endTime =
filterState.timeRange === 'custom' && filterState.customDateRange
? filterState.customDateRange.to
: now;
return {
startTime: startTime?.toISOString(),
endTime: endTime.toISOString(),
};
}, [filterState.timeRange, filterState.customDateRange]);
// Fetch data based on filters
const fetchData = useCallback(async () => {
const requestId = ++requestIdRef.current;
@@ -87,7 +48,10 @@ export function useMonitoringData(filterState: FilterState) {
setError(null);
try {
const { startTime, endTime } = getTimeRange();
const { startTime, endTime } = resolveMonitoringWindow(
filterState.timeRange,
filterState.customDateRange,
);
const response = await backendClient.getMonitoringData({
botId:
@@ -426,7 +390,8 @@ export function useMonitoringData(filterState: FilterState) {
if (isCurrent()) setLoading(false);
}
}, [
getTimeRange,
filterState.timeRange,
filterState.customDateRange,
filterState.selectedBots,
filterState.selectedPipelines,
scope,
+482 -270
View File
@@ -1,7 +1,16 @@
import React, { Suspense, useState, useMemo, useCallback } from 'react';
import React, {
Suspense,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
ChevronRight,
ChevronDown,
@@ -10,8 +19,11 @@ import {
MessageSquare,
Sparkles,
CheckCircle2,
Activity,
} from 'lucide-react';
import OverviewCards from './components/overview-cards/OverviewCards';
import { TabState } from './components/TabState';
import SystemStatusCard from './components/overview-cards/SystemStatusCards';
import TrafficChart from './components/overview-cards/TrafficChart';
import MonitoringFilters from './components/filters/MonitoringFilters';
import TokenMonitoring from './components/TokenMonitoring';
import { ExportDropdown } from './components/ExportDropdown';
@@ -21,8 +33,20 @@ import { useFeedbackData } from './hooks/useFeedbackData';
import { ConversationTurnList } from './components/ConversationTurnList';
import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList';
import ExecutionOverviewCards from './components/overview-cards/ExecutionOverviewCards';
import ExecutionTable from './components/executions/ExecutionTable';
import ExecutionDetailSheet from './components/executions/ExecutionDetailSheet';
import { useExecutions } from './hooks/useExecutions';
import type {
ExecutionModeFilter,
ExecutionRow,
ExecutionSourceFilter,
} from '@/app/infra/entities/api/monitoring-executions';
import { buildConversationTurns } from './utils/conversationTurns';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
import { resolveCallTarget } from './utils/callLinks';
import { cn } from '@/lib/utils';
import { resolveMonitoringWindow } from './utils/dateUtils';
import { LoadingPage } from '@/components/ui/loading-spinner';
import { useCurrentWorkspace } from '@/app/infra/http';
function MonitoringPageContent() {
@@ -34,48 +58,62 @@ function MonitoringPageContent() {
useMonitoringFilters();
const { data, loading, error, refetch } = useMonitoringData(filterState);
// Unified execution view (agent runs + pipeline queries). Its filters are
// independent of the legacy message/model tabs.
const [executionSource, setExecutionSource] =
useState<ExecutionSourceFilter>('all');
const [executionMode, setExecutionMode] =
useState<ExecutionModeFilter>('all');
const [executionStatus, setExecutionStatus] = useState<string>('all');
const [executionOffset, setExecutionOffset] = useState(0);
const [selectedExecution, setSelectedExecution] =
useState<ExecutionRow | null>(null);
const EXECUTIONS_PAGE_SIZE = 50;
const {
result: executionResult,
loading: executionLoading,
error: executionError,
refetch: refetchExecutions,
} = useExecutions({
selectedBots: filterState.selectedBots,
selectedPipelines: filterState.selectedPipelines,
selectedAgents: [],
source: executionSource,
mode: executionMode,
statusGroup: executionStatus,
timeRange: filterState.timeRange,
customDateRange: filterState.customDateRange,
limit: EXECUTIONS_PAGE_SIZE,
offset: executionOffset,
});
// Reset paging whenever the execution filters narrow the result set.
useEffect(() => {
setExecutionOffset(0);
}, [
executionSource,
executionMode,
executionStatus,
filterState.selectedBots,
filterState.selectedPipelines,
filterState.timeRange,
filterState.customDateRange,
]);
// Counter to force feedbackTimeRange recomputation on manual refresh
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
// Get time range for feedback data
const feedbackTimeRange = useMemo(() => {
const now = new Date();
let startTime: Date | null = null;
switch (filterState.timeRange) {
case 'lastHour':
startTime = new Date(now.getTime() - 60 * 60 * 1000);
break;
case 'last6Hours':
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
break;
case 'last24Hours':
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case 'last7Days':
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'last30Days':
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case 'custom':
if (filterState.customDateRange) {
startTime = filterState.customDateRange.from;
}
break;
}
const endTime =
filterState.timeRange === 'custom' && filterState.customDateRange
? filterState.customDateRange.to
: now;
return {
startTime: startTime?.toISOString(),
endTime: endTime.toISOString(),
};
const feedbackTimeRange = useMemo(
() =>
resolveMonitoringWindow(
filterState.timeRange,
filterState.customDateRange,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
[filterState.timeRange, filterState.customDateRange, feedbackRefreshKey],
);
// Feedback data hook
const {
@@ -99,8 +137,9 @@ function MonitoringPageContent() {
// Combined refresh handler for both monitoring and feedback data
const handleRefresh = useCallback(() => {
refetch();
refetchExecutions();
setFeedbackRefreshKey((k) => k + 1);
}, [refetch]);
}, [refetch, refetchExecutions]);
const conversationTurns = useMemo(
() =>
@@ -118,7 +157,14 @@ function MonitoringPageContent() {
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
// State for controlled tabs
const [activeTab, setActiveTab] = useState<string>('messages');
const [activeTab, setActiveTab] = useState<string>('executions');
// Ids the message list actually holds: a call may name a record that the
// current window does not contain, and a dead link is worse than no link.
const messageIds = useMemo(
() => new Set(data?.messages.map((message) => message.id) ?? []),
[data?.messages],
);
// Function to jump to a message record
const jumpToMessage = (messageId: string) => {
@@ -148,7 +194,7 @@ function MonitoringPageContent() {
{/* Filters and Refresh Button - Sticky */}
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<div className="flex flex-col gap-3 p-3 bg-card rounded-xl border sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-4 sm:p-4">
<Card className="flex flex-col gap-3 p-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-4 sm:p-4">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
@@ -156,6 +202,12 @@ function MonitoringPageContent() {
onBotsChange={setSelectedBots}
onPipelinesChange={setSelectedPipelines}
onTimeRangeChange={setTimeRange}
source={executionSource}
onSourceChange={setExecutionSource}
mode={executionMode}
onModeChange={setExecutionMode}
statusGroup={executionStatus}
onStatusGroupChange={setExecutionStatus}
/>
<div className="flex items-center gap-2">
{canExport && <ExportDropdown filterState={filterState} />}
@@ -169,29 +221,36 @@ function MonitoringPageContent() {
{t('monitoring.refreshData')}
</Button>
</div>
</div>
</Card>
</div>
</div>
{/* Content Area */}
{error ? (
<div
role="alert"
className="rounded-xl border border-destructive p-6 space-y-3"
>
<p>{t('monitoring.loadError')}</p>
<Button variant="outline" onClick={handleRefresh}>
{t('common.retry')}
</Button>
</div>
<Alert variant="destructive">
<AlertTitle>{t('monitoring.loadError')}</AlertTitle>
<AlertDescription className="mt-2">
<Button variant="outline" size="sm" onClick={handleRefresh}>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
) : (
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section */}
<OverviewCards
metrics={data?.overview || null}
traffic={data?.traffic}
loading={loading}
/>
{/* Overview Section: the execution metrics and the runtime status share
one grid, so a card always lands in the same row regardless of how
many metrics reported. */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
<ExecutionOverviewCards
summary={executionResult?.summary ?? null}
loading={executionLoading}
activeSessions={data?.overview?.activeSessions ?? null}
/>
<SystemStatusCard refreshKey={feedbackRefreshKey} />
</div>
{/* Traffic Chart */}
<TrafficChart traffic={data?.traffic} loading={loading} />
{/* Tabs Section */}
{!loading && data && (
@@ -235,14 +294,20 @@ function MonitoringPageContent() {
)}
</div>
)}
<div className="bg-card rounded-xl border overflow-hidden">
{/* Detail records region: one fixed-height surface for every tab. Tab
switches and expanded records scroll inside it, so the page never
resizes and nothing below the fold is pushed out of view. */}
<div className="bg-card flex h-[min(72vh,48rem)] min-h-[24rem] flex-col overflow-hidden rounded-xl border">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
className="flex h-full min-h-0 w-full flex-col gap-0"
>
<div className="px-3 pt-4 sm:px-6">
<div className="border-b px-3 pt-4 pb-3 sm:px-6">
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
<TabsTrigger value="executions" className="px-3 py-2 sm:px-6">
{t('monitoring.execution.tabs.executions')}
</TabsTrigger>
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.messages')}
</TabsTrigger>
@@ -261,15 +326,99 @@ function MonitoringPageContent() {
</TabsList>
</div>
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
<TabsContent
value="executions"
className="m-0 flex min-h-0 flex-1 flex-col"
>
<p className="px-3 pt-4 text-sm text-muted-foreground sm:px-6">
{t('monitoring.execution.subtitle')}
</p>
{executionError ? (
<div className="px-3 pt-4 sm:px-6">
<Alert variant="destructive">
<AlertTitle>
{t('monitoring.execution.detail.loadError')}
</AlertTitle>
<AlertDescription className="mt-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
</div>
) : (
<>
<div className="min-h-0 flex-1 overflow-hidden px-3 pt-4 pb-4 sm:px-6">
{executionLoading ? (
<TabState loading rows={6} />
) : (executionResult?.items.length ?? 0) === 0 ? (
<TabState
icon={<Activity className="h-12 w-12" />}
title={t('monitoring.execution.empty')}
/>
) : (
<ExecutionTable
rows={executionResult?.items ?? []}
selectedId={selectedExecution?.id}
onSelect={setSelectedExecution}
/>
)}
</div>
)}
{executionResult &&
executionResult.total > EXECUTIONS_PAGE_SIZE && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-3 py-3 sm:px-6">
<Button
variant="outline"
size="sm"
disabled={executionOffset === 0}
onClick={() =>
setExecutionOffset(
Math.max(
0,
executionOffset - EXECUTIONS_PAGE_SIZE,
),
)
}
>
{t('common.previous')}
</Button>
<span className="text-xs text-muted-foreground">
{executionOffset + 1}–
{Math.min(
executionOffset + EXECUTIONS_PAGE_SIZE,
executionResult.total,
)}{' '}
/ {executionResult.total}
</span>
<Button
variant="outline"
size="sm"
disabled={!executionResult.has_more}
onClick={() =>
setExecutionOffset(
executionOffset + EXECUTIONS_PAGE_SIZE,
)
}
>
{t('common.next')}
</Button>
</div>
)}
</>
)}
</TabsContent>
<TabsContent
value="messages"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<div>
{loading && <TabState loading rows={6} />}
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
@@ -280,214 +429,272 @@ function MonitoringPageContent() {
)}
{!loading && (!data || conversationTurns.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</div>
</div>
<TabState
icon={<MessageSquare className="h-12 w-12" />}
title={t('monitoring.messageList.noMessages')}
/>
)}
</div>
</TabsContent>
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
<TabsContent
value="modelCalls"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{loading && <TabState loading rows={6} />}
{!loading &&
data &&
data.modelCalls &&
data.modelCalls.length > 0 && (
<div className="space-y-4">
{data.modelCalls.map((call) => (
<div
key={call.id}
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
{/* Query ID - only show if messageId exists */}
{call.messageId && (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={() =>
jumpToMessage(call.messageId!)
{data.modelCalls.map((call) => {
// A recorded call names the record it belongs to, so
// the whole card opens it; without a match the card
// stays static instead of pretending to be a link.
const target = resolveCallTarget(
call,
executionResult?.items ?? [],
messageIds,
);
const openTarget = () => {
if (!target) return;
if (target.kind === 'execution') {
setActiveTab('executions');
setSelectedExecution(target.row);
return;
}
jumpToMessage(target.id);
};
return (
<Card
key={call.id}
role={target ? 'button' : undefined}
tabIndex={target ? 0 : undefined}
aria-label={
target
? t('monitoring.execution.detail.openObject')
: undefined
}
onClick={target ? openTarget : undefined}
onKeyDown={
target
? (
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (
event.key === 'Enter' ||
event.key === ' '
) {
event.preventDefault();
openTarget();
}
}
: undefined
}
className={cn(
'gap-0 px-4 py-4 transition-all duration-200 sm:px-5 sm:py-5',
target &&
'cursor-pointer hover:bg-muted/40 focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none',
)}
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
{/* The stored id is the owning record: a
Pipeline's message, or an Agent run's id
that the card itself opens. */}
{call.messageId &&
messageIds.has(call.messageId) && (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(event) => {
// The card itself opens the call's
// record; this button is the more
// specific message target.
event.stopPropagation();
jumpToMessage(call.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
</div>
)}
<div className="flex items-center gap-2 mb-2">
{/* Model Type Badge */}
<Badge
variant="outline"
className={
call.modelType === 'llm'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
{call.modelType === 'llm'
? t('monitoring.modelCalls.llmModel')
: t(
'monitoring.modelCalls.embeddingModel',
)}
</Badge>
{/* Call Type Badge for Embedding */}
{call.modelType === 'embedding' &&
call.callType && (
<Badge
variant="outline"
className={
call.callType === 'retrieve'
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
>
{call.callType === 'retrieve'
? t(
'monitoring.modelCalls.retrieveCall',
)
: t(
'monitoring.modelCalls.embeddingCall',
)}
</Badge>
)}
</Button>
{/* Status Badge */}
<Badge
variant="outline"
className={
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}
>
{call.status}
</Badge>
</div>
)}
<div className="flex items-center gap-2 mb-2">
{/* Model Type Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.modelType === 'llm'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}
>
{call.modelType === 'llm'
? t('monitoring.modelCalls.llmModel')
: t(
'monitoring.modelCalls.embeddingModel',
)}
</span>
{/* Call Type Badge for Embedding */}
{call.modelType === 'embedding' &&
call.callType && (
<span
className={`text-xs px-2 py-1 rounded ${
call.callType === 'retrieve'
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}`}
>
{call.callType === 'retrieve'
? t(
'monitoring.modelCalls.retrieveCall',
)
: t(
'monitoring.modelCalls.embeddingCall',
)}
</span>
{/* Model Name */}
<div className="font-medium text-sm text-foreground mb-2">
{call.modelName}
</div>
{/* Context Info - only for LLM calls */}
{call.modelType === 'llm' &&
call.botName &&
call.pipelineName && (
<div className="text-xs text-muted-foreground mb-1">
{call.botName} → {call.pipelineName}
</div>
)}
{/* Status Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
{/* Model Name */}
<div className="font-medium text-sm text-foreground mb-2">
{call.modelName}
</div>
{/* Context Info - only for LLM calls */}
{call.modelType === 'llm' &&
call.botName &&
call.pipelineName && (
<div className="text-xs text-muted-foreground mb-1">
{call.botName} → {call.pipelineName}
</div>
)}
{/* Token Info */}
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex flex-wrap gap-4">
{call.modelType === 'llm' &&
call.tokens && (
{/* Token Info */}
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex flex-wrap gap-4">
{call.modelType === 'llm' &&
call.tokens && (
<>
<span>
{t(
'monitoring.llmCalls.inputTokens',
)}
: {call.tokens.input}
</span>
<span>
{t(
'monitoring.llmCalls.outputTokens',
)}
: {call.tokens.output}
</span>
<span>
{t(
'monitoring.llmCalls.totalTokens',
)}
: {call.tokens.total}
</span>
</>
)}
{call.modelType === 'embedding' && (
<>
<span>
{t(
'monitoring.llmCalls.inputTokens',
'monitoring.embeddingCalls.promptTokens',
)}
: {call.tokens.input}
: {call.promptTokens}
</span>
<span>
{t(
'monitoring.llmCalls.outputTokens',
'monitoring.embeddingCalls.totalTokens',
)}
: {call.tokens.output}
: {call.totalTokens}
</span>
<span>
{t(
'monitoring.llmCalls.totalTokens',
'monitoring.embeddingCalls.inputCount',
)}
: {call.tokens.total}
: {call.inputCount}
</span>
</>
)}
{call.modelType === 'embedding' && (
<>
<span>
{t(
'monitoring.embeddingCalls.promptTokens',
)}
: {call.promptTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.totalTokens',
)}
: {call.totalTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.inputCount',
)}
: {call.inputCount}
</span>
</>
)}
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
)}
</div>
{/* Knowledge Base Info for Embedding */}
{call.modelType === 'embedding' &&
call.knowledgeBaseId && (
<div>
{t(
'monitoring.embeddingCalls.knowledgeBase',
)}
: {call.knowledgeBaseId}
</div>
)}
{/* Query Text for Embedding Retrieve */}
{call.modelType === 'embedding' &&
call.queryText && (
<div className="mt-2 p-2 bg-muted rounded text-sm">
<span className="text-muted-foreground">
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
</span>
)}
</div>
{/* Knowledge Base Info for Embedding */}
{call.modelType === 'embedding' &&
call.knowledgeBaseId && (
<div>
{t(
'monitoring.embeddingCalls.queryText',
'monitoring.embeddingCalls.knowledgeBase',
)}
:{' '}
</span>
<span className="text-foreground">
{call.queryText.length > 100
? call.queryText.substring(0, 100) +
'...'
: call.queryText}
</span>
</div>
)}
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
: {call.knowledgeBaseId}
</div>
)}
{/* Query Text for Embedding Retrieve */}
{call.modelType === 'embedding' &&
call.queryText && (
<div className="mt-2 p-2 bg-muted rounded text-sm">
<span className="text-muted-foreground">
{t(
'monitoring.embeddingCalls.queryText',
)}
:{' '}
</span>
<span className="text-foreground">
{call.queryText.length > 100
? call.queryText.substring(
0,
100,
) + '...'
: call.queryText}
</span>
</div>
)}
</div>
)}
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
</div>
)}
</div>
<span className="ml-4 flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
{call.timestamp.toLocaleString()}
{target && (
<ChevronRight className="h-4 w-4" />
)}
</span>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()}
</span>
</div>
</div>
))}
</Card>
);
})}
</div>
)}
@@ -495,17 +702,18 @@ function MonitoringPageContent() {
(!data ||
!data.modelCalls ||
data.modelCalls.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<Sparkles className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.modelCalls.noData')}
</div>
</div>
<TabState
icon={<Sparkles className="h-12 w-12" />}
title={t('monitoring.modelCalls.noData')}
/>
)}
</div>
</TabsContent>
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
<TabsContent
value="tokens"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<TokenMonitoring
botIds={
filterState.selectedBots.length > 0
@@ -523,13 +731,12 @@ function MonitoringPageContent() {
/>
</TabsContent>
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
<TabsContent
value="feedback"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{loading && <TabState loading rows={6} />}
{!loading && (
<>
@@ -542,7 +749,7 @@ function MonitoringPageContent() {
</div>
{/* Feedback List */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
<h3 className="text-lg font-semibold text-foreground mb-4">
{t('monitoring.feedback.feedbackList')}
</h3>
<FeedbackList
@@ -555,13 +762,12 @@ function MonitoringPageContent() {
</div>
</TabsContent>
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
<TabsContent
value="errors"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{loading && <TabState loading rows={6} />}
{!loading &&
data &&
@@ -569,9 +775,9 @@ function MonitoringPageContent() {
data.errors.length > 0 && (
<div className="space-y-4">
{data.errors.map((error) => (
<div
<Card
key={error.id}
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
className="gap-0 overflow-hidden border-red-200 py-0 transition-all duration-200 dark:border-red-900"
>
{/* Error Header - Always Visible */}
<div
@@ -704,19 +910,19 @@ function MonitoringPageContent() {
</div>
</div>
)}
</div>
</Card>
))}
</div>
)}
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
<div className="text-sm text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</div>
</div>
<TabState
icon={
<CheckCircle2 className="h-12 w-12 text-green-500 dark:text-green-600" />
}
title={t('monitoring.errors.noErrors')}
/>
)}
</div>
</TabsContent>
@@ -724,6 +930,12 @@ function MonitoringPageContent() {
</div>
</div>
)}
{/* Execution trace drawer */}
<ExecutionDetailSheet
row={selectedExecution}
onClose={() => setSelectedExecution(null)}
/>
</div>
);
}
@@ -0,0 +1,100 @@
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
/**
* The identity a recorded call carries: every call kind (model, tool) has a time,
* a session and - when a Pipeline recorded it - the message it belongs to.
*/
export interface CallLinkSource {
timestamp: Date;
sessionId?: string | null;
messageId?: string | null;
}
/** A record the dashboard can open for a recorded model or tool call. */
export type CallTarget =
| { kind: 'execution'; row: ExecutionRow }
| { kind: 'message'; id: string };
/** Calls are matched against a run's window; clock skew must not lose the link. */
const WINDOW_SLACK_MS = 2000;
function containsCall(row: ExecutionRow, callTime: number): boolean {
if (row.started_at_ms == null) {
return false;
}
const started = row.started_at_ms;
if (callTime < started - WINDOW_SLACK_MS) {
return false;
}
if (row.finished_at_ms == null) {
// A run with no end time is still open: anything after its start belongs to it.
return true;
}
return callTime <= row.finished_at_ms + WINDOW_SLACK_MS;
}
/**
* The execution a recorded call ran inside.
*
* A Pipeline call names its message, which is the same id the execution list
* uses for that query. An Agent call names nothing, so it is matched to the run
* sharing its session whose window contains the call; the latest such start
* wins, so a session with consecutive runs links to the run that was live.
*/
export function findCallExecution(
call: CallLinkSource,
executions: ExecutionRow[],
): ExecutionRow | null {
if (executions.length === 0) {
return null;
}
const messageId = call.messageId;
if (messageId) {
const direct = executions.find((row) => row.id === messageId);
if (direct) {
return direct;
}
}
const sessionId = call.sessionId;
const callTime = call.timestamp.getTime();
let matched: ExecutionRow | null = null;
for (const row of executions) {
if (
!sessionId ||
row.session_id !== sessionId ||
!containsCall(row, callTime)
) {
continue;
}
if (
matched == null ||
(row.started_at_ms ?? 0) > (matched.started_at_ms ?? 0)
) {
matched = row;
}
}
return matched;
}
/**
* Where a call's card jumps to: its execution record when the loaded list knows
* it, else the pipeline message it names. An Agent call carries its run id, so
* the message fallback applies only to ids the message list actually holds.
*/
export function resolveCallTarget(
call: CallLinkSource,
executions: ExecutionRow[],
messageIds?: ReadonlySet<string>,
): CallTarget | null {
const row = findCallExecution(call, executions);
if (row) {
return { kind: 'execution', row };
}
const messageId = call.messageId;
if (messageId && messageIds?.has(messageId)) {
return { kind: 'message', id: messageId };
}
return null;
}
@@ -116,3 +116,48 @@ export function parseUTCTimestamp(timestamp: string): Date {
}
return new Date(timestamp + 'Z');
}
/**
* Resolve a monitoring time window to ISO start/end strings.
*
* Single source of the window semantics for the dashboard, the unified
* execution list, and the feedback queries.
*/
export function resolveMonitoringWindow(
timeRange: TimeRangeOption,
customDateRange: DateRange | null,
): { startTime: string; endTime: string } {
const now = new Date();
let startTime: Date | null = null;
switch (timeRange) {
case 'lastHour':
startTime = new Date(now.getTime() - 60 * 60 * 1000);
break;
case 'last6Hours':
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
break;
case 'last24Hours':
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case 'last7Days':
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'last30Days':
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case 'custom':
if (customDateRange) {
startTime = customDateRange.from;
}
break;
}
const endTime =
timeRange === 'custom' && customDateRange ? customDateRange.to : now;
return {
startTime: (startTime ?? now).toISOString(),
endTime: endTime.toISOString(),
};
}
+17
View File
@@ -950,3 +950,20 @@ export interface ApiRespSkills {
export interface ApiRespSkill {
skill: Skill;
}
export type {
ExecutionSource,
ExecutionSourceFilter,
ExecutionModeFilter,
ExecutionUsage,
ExecutionRow,
ExecutionOverview,
TokenCoverage,
ExecutionSummary,
ExecutionListResult,
PipelineMessageRow,
ExecutionCallRow,
AgentExecutionDetail,
PipelineExecutionDetail,
ExecutionDetail,
} from './monitoring-executions';
@@ -0,0 +1,136 @@
import type { ProcessorRun, ProcessorRunEvent } from './index';
export type ExecutionSource = 'agent' | 'pipeline';
export type ExecutionSourceFilter = 'all' | ExecutionSource;
export type ExecutionModeFilter = 'all' | 'real' | 'debug';
export interface ExecutionUsage {
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
}
export interface ExecutionRow {
source: ExecutionSource;
id: string;
event_id: string | null;
status: string;
status_group:
| 'completed'
| 'failed'
| 'running'
| 'queued'
| 'cancelled'
| 'ignored'
| string;
title: string;
target_kind: 'agent' | 'pipeline' | 'processor' | string;
target_id: string | null;
target_name: string | null;
bot_id: string | null;
bot_name: string | null;
pipeline_id: string | null;
pipeline_name: string | null;
runner_id: string | null;
conversation_id: string | null;
session_id: string | null;
created_at_ms: number | null;
started_at_ms: number | null;
finished_at_ms: number | null;
duration_ms: number | null;
usage: ExecutionUsage | null;
cost: Record<string, unknown> | null;
queue_name: string | null;
debug: boolean;
has_error: boolean;
platform?: string | null;
user_id?: string | null;
user_name?: string | null;
}
export interface ExecutionOverview {
total: number;
by_source: { agent: number; pipeline: number };
by_status: Record<string, number>;
completed: number;
failed: number;
running: number;
queued: number;
cancelled: number;
ignored: number;
waiting: number;
success_rate: number | null;
denominator: number;
p50_duration_ms: number | null;
p95_duration_ms: number | null;
duration_sample: number;
debug: number;
real: number;
}
export interface TokenCoverage {
total_tokens: number;
calls: number;
calls_with_usage: number;
}
export interface ExecutionSummary {
executions: ExecutionOverview;
tokens: TokenCoverage;
}
export interface ExecutionListResult {
items: ExecutionRow[];
total: number;
limit: number;
offset: number;
has_more: boolean;
summary: ExecutionSummary;
}
export interface PipelineMessageRow {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform?: string | null;
user_id?: string | null;
user_name?: string | null;
runner_name?: string | null;
role?: string | null;
}
export interface ExecutionCallRow {
id: string;
timestamp: string;
/** LLM/tool calls report a status; error records carry `error_type` instead. */
status?: string | null;
duration?: number | null;
error_type?: string | null;
error_message?: string | null;
[key: string]: unknown;
}
export interface AgentExecutionDetail {
source: 'agent';
run: ProcessorRun;
events: ProcessorRunEvent[];
has_more: boolean;
next_cursor: number | null;
}
export interface PipelineExecutionDetail {
source: 'pipeline';
message: PipelineMessageRow;
llm_calls: ExecutionCallRow[];
tool_calls: ExecutionCallRow[];
errors: ExecutionCallRow[];
}
export type ExecutionDetail = AgentExecutionDetail | PipelineExecutionDetail;
+65
View File
@@ -4,6 +4,13 @@ import type {
PipelineMigrationRequest,
} from '@/app/infra/entities/api/pipeline-migration';
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
import type {
ExecutionDetail,
ExecutionListResult,
ExecutionModeFilter,
ExecutionSource,
ExecutionSourceFilter,
} from '../entities/api/monitoring-executions';
import type {
CodexAuthStatus,
CodexDeviceAuthorization,
@@ -2115,6 +2122,64 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
}
public getExecutions(params: {
botId?: string[];
pipelineId?: string[];
agentId?: string[];
status?: string[];
source?: ExecutionSourceFilter;
mode?: ExecutionModeFilter;
startTime?: string;
endTime?: string;
limit?: number;
offset?: number;
}): Promise<ExecutionListResult> {
const queryParams = new URLSearchParams();
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineId) {
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
}
if (params.agentId) {
params.agentId.forEach((id) => queryParams.append('agentId', id));
}
if (params.status) {
params.status.forEach((status) => queryParams.append('status', status));
}
if (params.source) {
queryParams.append('source', params.source);
}
if (params.mode) {
queryParams.append('mode', params.mode);
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
if (params.limit) {
queryParams.append('limit', params.limit.toString());
}
if (params.offset) {
queryParams.append('offset', params.offset.toString());
}
return this.get(
`/api/v1/monitoring/executions?${queryParams.toString()}`,
);
}
public getExecutionDetail(
source: ExecutionSource,
executionId: string,
): Promise<ExecutionDetail> {
return this.get(
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}`,
);
}
public getMonitoringOverview(params: {
botId?: string[];
pipelineId?: string[];
+9 -2
View File
@@ -2,11 +2,18 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
// `containerClassName` styles the scroll container shadcn wraps every table in.
// A caller that owns the scrolling region (a fixed-height panel) uses it to make
// that container the scroller, which is what keeps a sticky header working.
function Table({
className,
containerClassName,
...props
}: React.ComponentProps<'table'> & { containerClassName?: string }) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className={cn('relative w-full overflow-x-auto', containerClassName)}
>
<table
data-slot="table"
+77
View File
@@ -2456,6 +2456,83 @@ const enUS = {
},
},
monitoring: {
execution: {
title: 'Executions',
subtitle:
'Every inbound event and the execution it triggered, across agents and pipelines.',
tabs: { executions: 'Executions' },
filters: {
source: 'Type',
sourceAll: 'All types',
sourceAgent: 'Agents',
sourcePipeline: 'Pipelines',
mode: 'Traffic',
modeAll: 'Live + debug',
modeLive: 'Live only',
modeDebug: 'Debug only',
status: 'Status',
statusAll: 'All statuses',
},
status: {
completed: 'Completed',
failed: 'Failed',
running: 'Running',
queued: 'Queued',
cancelled: 'Cancelled',
ignored: 'Ignored',
},
kind: { agent: 'Agent', pipeline: 'Pipeline', processor: 'Processor' },
columns: {
time: 'Time',
target: 'Processor',
status: 'Status',
duration: 'Duration',
tokens: 'Tokens',
},
debug: 'Debug',
empty: 'No executions in this range',
loading: 'Loading executions...',
triggeredBy: 'Triggered by {{trigger}}',
cards: {
totalExecutions: 'Executions',
bySource: '{{agent}} agent · {{pipeline}} pipeline',
successRate: 'Execution Success Rate',
successRateHint:
'Completed ÷ (completed + failed). Ignored events and in-flight runs are excluded from the denominator.',
denominator: '{{completed}} of {{denominator}} settled',
running: 'Running',
queued: 'Queued',
waiting: 'Waiting input',
failed: 'Failed',
ignored: 'Ignored',
latency: 'Duration P50 / P95',
p50: 'P50',
p95: 'P95',
sample: 'sample of {{count}} runs',
tokens: 'Tokens',
coverage: 'usage on {{withUsage}}/{{calls}} calls',
hiddenDebug: '{{count}} debug runs hidden',
},
detail: {
title: 'Execution',
timeline: 'Timeline',
executionId: 'Execution ID',
eventId: 'Event',
queue: 'Queue',
runner: 'Runner',
created: 'Created',
duration: 'Duration',
usage: 'Usage',
noEvents: 'No trace events recorded',
query: 'Inbound message',
modelCalls: 'Model calls',
toolCalls: 'Tool calls',
errors: 'Errors',
loadError: 'Failed to load execution trace',
openObject: 'Open object',
a11yDescription: 'Full trace of this execution',
},
},
title: 'Dashboard',
description: 'Monitor bot activities, LLM calls, and system performance',
overview: 'Overview',
+76
View File
@@ -2308,6 +2308,82 @@ const zhHans = {
},
},
monitoring: {
execution: {
title: '执行记录',
subtitle: '每条入站事件及其触发的执行,覆盖 Agent 与 Pipeline。',
tabs: { executions: '执行记录' },
filters: {
source: '类型',
sourceAll: '全部类型',
sourceAgent: 'Agent',
sourcePipeline: 'Pipeline',
mode: '流量',
modeAll: '真实 + 调试',
modeLive: '仅真实',
modeDebug: '仅调试',
status: '状态',
statusAll: '全部状态',
},
status: {
completed: '已完成',
failed: '失败',
running: '运行中',
queued: '排队中',
cancelled: '已取消',
ignored: '已忽略',
},
kind: { agent: 'Agent', pipeline: 'Pipeline', processor: '处理器' },
columns: {
time: '时间',
target: '处理器',
status: '状态',
duration: '耗时',
tokens: 'Token',
},
debug: '调试',
empty: '该时间范围内暂无执行记录',
loading: '加载执行记录中...',
triggeredBy: '触发:{{trigger}}',
cards: {
totalExecutions: '执行总数',
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
successRate: '执行成功率',
successRateHint:
'已完成 ÷(已完成 + 失败)。已忽略事件与执行中的任务不计入分母。',
denominator: '{{completed}} / {{denominator}} 已结束',
running: '运行中',
queued: '排队中',
waiting: '等待交互',
failed: '失败',
ignored: '已忽略',
latency: '耗时 P50 / P95',
p50: 'P50',
p95: 'P95',
sample: '样本 {{count}} 次',
tokens: 'Token',
coverage: '{{withUsage}}/{{calls}} 次调用有用量',
hiddenDebug: '已隐藏 {{count}} 条调试运行',
},
detail: {
title: '执行详情',
timeline: '执行时间线',
executionId: '执行 ID',
eventId: '事件',
queue: '队列',
runner: 'Runner',
created: '创建时间',
duration: '耗时',
usage: '用量',
noEvents: '暂无追踪事件',
query: '入站消息',
modelCalls: '模型调用',
toolCalls: '工具调用',
errors: '错误',
loadError: '加载执行轨迹失败',
openObject: '打开对象',
a11yDescription: '此次执行的完整追踪',
},
},
title: '仪表盘',
description: '查看机器人活动、LLM调用和系统性能',
overview: '概览',
+76
View File
@@ -2309,6 +2309,82 @@ const zhHant = {
},
},
monitoring: {
execution: {
title: '執行記錄',
subtitle: '每條入站事件及其觸發的執行,涵蓋 Agent 與 Pipeline。',
tabs: { executions: '執行記錄' },
filters: {
source: '類型',
sourceAll: '全部類型',
sourceAgent: 'Agent',
sourcePipeline: 'Pipeline',
mode: '流量',
modeAll: '真實 + 偵錯',
modeLive: '僅真實',
modeDebug: '僅偵錯',
status: '狀態',
statusAll: '全部狀態',
},
status: {
completed: '已完成',
failed: '失敗',
running: '執行中',
queued: '排隊中',
cancelled: '已取消',
ignored: '已忽略',
},
kind: { agent: 'Agent', pipeline: 'Pipeline', processor: '處理器' },
columns: {
time: '時間',
target: '處理器',
status: '狀態',
duration: '耗時',
tokens: 'Token',
},
debug: '偵錯',
empty: '此時間範圍內暫無執行記錄',
triggeredBy: '觸發:{{trigger}}',
loading: '載入執行記錄中...',
cards: {
totalExecutions: '執行總數',
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
successRate: '執行成功率',
successRateHint:
'已完成 ÷(已完成 + 失敗)。已忽略事件與執行中的任務不計入分母。',
denominator: '{{completed}} / {{denominator}} 已結束',
running: '執行中',
queued: '排隊中',
waiting: '等待互動',
failed: '失敗',
ignored: '已忽略',
latency: '耗時 P50 / P95',
p50: 'P50',
p95: 'P95',
sample: '樣本 {{count}} 次',
tokens: 'Token',
coverage: '{{withUsage}}/{{calls}} 次呼叫有用量',
hiddenDebug: '已隱藏 {{count}} 筆偵錯執行',
},
detail: {
title: '執行詳情',
timeline: '執行時間軸',
executionId: '執行 ID',
eventId: '事件',
queue: '佇列',
runner: 'Runner',
created: '建立時間',
duration: '耗時',
usage: '用量',
noEvents: '暫無追蹤事件',
query: '入站訊息',
modelCalls: '模型呼叫',
toolCalls: '工具呼叫',
errors: '錯誤',
loadError: '載入執行軌跡失敗',
openObject: '開啟物件',
a11yDescription: '此次執行的完整追蹤',
},
},
title: '儀表盤',
description: '監控機器人活動、LLM調用和系統效能',
overview: '概覽',
+157
View File
@@ -227,6 +227,123 @@ function emptyMonitoringData() {
};
}
type RawMonitoringMessage = Record<string, unknown>;
/** Mirror of the backend execution status groups so fixtures stay realistic. */
function executionStatusGroup(status: string, source: 'agent' | 'pipeline') {
if (source === 'agent') {
if (status === 'failed' || status === 'timeout') return 'failed';
if (status === 'created' || status === 'queued') return 'queued';
if (status === 'claimed' || status === 'running') return 'running';
if (status === 'cancelled') return 'cancelled';
return status;
}
if (status === 'success') return 'completed';
if (status === 'error') return 'failed';
if (status === 'pending') return 'running';
if (status === 'discarded' || status === 'not_matched') return 'ignored';
return status;
}
/** Best-effort text extraction from a serialized message chain (mirrors backend). */
function executionText(raw: unknown): string {
const source = String(raw ?? '');
try {
const parts: string[] = [];
const walk = (node: unknown, depth = 0): void => {
if (depth > 6) return;
if (Array.isArray(node)) {
node.forEach((item) => walk(item, depth + 1));
} else if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node)) {
if (key === 'text' && typeof value === 'string') parts.push(value);
else walk(value, depth + 1);
}
}
};
walk(JSON.parse(source));
return parts.join(' ').trim() || source;
} catch {
return source;
}
}
/** Derive the unified execution list from a mocked monitoring payload. */
function buildExecutionList(monitoringData: unknown) {
const payload = monitoringData as { messages?: RawMonitoringMessage[] } | undefined;
const items = (payload?.messages ?? [])
.filter((message) => message.role !== 'assistant')
.map((message) => {
const status = String(message.status ?? 'success');
return {
source: 'pipeline' as const,
id: String(message.id),
event_id: null,
status,
status_group: executionStatusGroup(status, 'pipeline'),
title: executionText(message.message_content),
target_kind: 'pipeline' as const,
target_id: (message.pipeline_id as string) ?? null,
target_name: (message.pipeline_name as string) ?? null,
bot_id: (message.bot_id as string) ?? null,
bot_name: (message.bot_name as string) ?? null,
pipeline_id: (message.pipeline_id as string) ?? null,
pipeline_name: (message.pipeline_name as string) ?? null,
runner_id: (message.runner_name as string) ?? null,
conversation_id: null,
session_id: (message.session_id as string) ?? null,
created_at_ms: Date.parse(String(message.timestamp ?? '')) || null,
started_at_ms: null,
finished_at_ms: null,
duration_ms: null,
usage: null,
cost: null,
queue_name: null,
debug: false,
has_error: status === 'error',
};
});
const byStatus: Record<string, number> = {};
for (const item of items) {
byStatus[item.status_group] = (byStatus[item.status_group] ?? 0) + 1;
}
const completed = byStatus.completed ?? 0;
const failed = byStatus.failed ?? 0;
const denominator = completed + failed;
return {
items,
total: items.length,
limit: items.length,
offset: 0,
has_more: false,
summary: {
executions: {
total: items.length,
by_source: { agent: 0, pipeline: items.length },
by_status: byStatus,
completed,
failed,
running: byStatus.running ?? 0,
queued: byStatus.queued ?? 0,
cancelled: byStatus.cancelled ?? 0,
ignored: byStatus.ignored ?? 0,
waiting: 0,
success_rate:
denominator > 0 ? Math.round((completed / denominator) * 10000) / 100 : null,
denominator,
p50_duration_ms: null,
p95_duration_ms: null,
duration_sample: 0,
debug: 0,
real: items.length,
},
tokens: { total_tokens: 0, calls: 0, calls_with_usage: 0 },
},
};
}
function emptyTokenStatistics() {
return {
summary: {
@@ -1163,6 +1280,46 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
}
if (path === '/api/v1/monitoring/executions') {
return fulfillJson(route, buildExecutionList(state.monitoringData));
}
const executionDetail = path.match(
/^\/api\/v1\/monitoring\/executions\/(agent|pipeline)\/(.+)$/,
);
if (executionDetail) {
const [, source, executionId] = executionDetail;
if (source === 'agent') {
return fulfillJson(route, {
source: 'agent',
run: { run_id: executionId, status: 'completed', created_at: 0 },
events: [],
has_more: false,
next_cursor: null,
});
}
const payload = state.monitoringData as {
messages?: RawMonitoringMessage[];
llmCalls?: RawMonitoringMessage[];
toolCalls?: RawMonitoringMessage[];
errors?: RawMonitoringMessage[];
};
const relatedToExecution = (rows: RawMonitoringMessage[] | undefined) =>
(rows ?? []).filter((row) => row.message_id === executionId);
return fulfillJson(route, {
source: 'pipeline',
message:
(payload.messages ?? []).find((row) => row.id === executionId) ?? {
id: executionId,
status: 'success',
message_content: '',
},
llm_calls: relatedToExecution(payload.llmCalls),
tool_calls: relatedToExecution(payload.toolCalls),
errors: relatedToExecution(payload.errors),
});
}
if (path === '/api/v1/monitoring/token-statistics') {
return fulfillJson(route, emptyTokenStatistics());
}
+8 -1
View File
@@ -59,7 +59,14 @@ test.describe('authenticated app shell', () => {
await page.goto('/home/monitoring');
await expect(page).toHaveURL(/\/home\/monitoring$/);
await expect(page.getByText('Total Messages').first()).toBeVisible();
// The unified execution view is the default entry point.
await expect(page.getByRole('tab', { name: 'Executions' })).toHaveAttribute(
'data-state',
'active',
);
await expect(
page.getByText('Execution Success Rate').first(),
).toBeVisible();
await expect(
page.getByRole('tab', { name: 'Message Records' }),
).toBeVisible();
+4
View File
@@ -304,6 +304,7 @@ test.describe('monitoring request contracts', () => {
await expect(page.getByText('No message records')).toHaveCount(0);
failing = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await page.getByRole('tab', { name: 'Message Records' }).click();
await expect(
page.getByText('Recovered monitoring', { exact: true }),
).toBeVisible();
@@ -324,6 +325,8 @@ test.describe('monitoring request contracts', () => {
await page.getByRole('option', { name: /Last 7 days/i }).click();
await expect.poll(() => pending.length).toBe(3);
await respond(pending[2], 'Latest filter data');
// The message-turn view now lives behind its own tab.
await page.getByRole('tab', { name: 'Message Records' }).click();
await expect(
page.getByText('Latest filter data', { exact: true }),
).toBeVisible();
@@ -598,6 +601,7 @@ test.describe('monitoring conversation turn grouping', () => {
});
await page.goto('/home/monitoring');
await page.getByRole('tab', { name: 'Message Records' }).click();
await expect(page.getByText('3 conversation turns')).toBeVisible();
await expect(
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// The resolver is pure: recorded calls carry a time and a session, the execution
// list carries the runs' windows, so the link is decided without any re-query.
const source = fs.readFileSync(
new URL('../../src/app/home/monitoring/utils/callLinks.ts', import.meta.url),
'utf8',
);
const module = { exports: {} };
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(module.exports);
const { findCallExecution, resolveCallTarget } = module.exports;
const at = (iso) => new Date(iso);
function run(overrides) {
return {
source: 'agent',
id: 'run-1',
session_id: 'session-a',
started_at_ms: at('2026-10-07T10:00:00Z').getTime(),
finished_at_ms: at('2026-10-07T10:00:30Z').getTime(),
...overrides,
};
}
function call(overrides) {
return {
id: 'call-1',
timestamp: at('2026-10-07T10:00:10Z'),
sessionId: 'session-a',
...overrides,
};
}
test('links a pipeline call to the execution carrying its message id', () => {
const execution = run({
source: 'pipeline',
id: 'message-9',
session_id: null,
});
assert.equal(
findCallExecution(call({ messageId: 'message-9', sessionId: undefined }), [
execution,
]),
execution,
);
});
test('links an agent call to the run whose window contains it', () => {
const execution = run({});
assert.equal(findCallExecution(call({}), [execution]), execution);
});
test('prefers the run that was live when a session has consecutive runs', () => {
const first = run({ id: 'run-1' });
const second = run({
id: 'run-2',
started_at_ms: at('2026-10-07T10:05:00Z').getTime(),
finished_at_ms: at('2026-10-07T10:05:30Z').getTime(),
});
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T10:05:10Z') }), [
first,
second,
]).id,
'run-2',
);
});
test('an open run owns every later call of its session', () => {
const execution = run({ finished_at_ms: null });
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T10:20:00Z') }), [
execution,
]),
execution,
);
});
test('a call outside every window links to nothing', () => {
const execution = run({});
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T11:00:00Z') }), [
execution,
]),
null,
);
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T09:00:00Z') }), [
execution,
]),
null,
);
assert.equal(
findCallExecution(call({ sessionId: 'other' }), [execution]),
null,
);
});
test('a call keeps its message target when the execution list cannot resolve it', () => {
assert.deepEqual(
resolveCallTarget(
call({ messageId: 'message-9', sessionId: 'other' }),
[run({})],
new Set(['message-9']),
),
{ kind: 'message', id: 'message-9' },
);
// An Agent call carries its run id in the same field: an id the message list
// does not hold must not become a jump to the message tab.
assert.equal(
resolveCallTarget(
call({ messageId: 'run-9', sessionId: 'other' }),
[run({})],
new Set(['message-9']),
),
null,
);
assert.equal(resolveCallTarget(call({}), []), null);
});