mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
feat(monitoring): unify execution views and trace tool calls
This commit is contained in:
@@ -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"""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '概览',
|
||||
|
||||
@@ -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: '概覽',
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user