mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
feat(agent-platform): show bot route runtime status
This commit is contained in:
@@ -38,6 +38,10 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
||||
return self.success(data={'logs': logs, 'total_count': total_count})
|
||||
|
||||
@self.route('/<bot_uuid>/event-routes/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
return self.success(data=await self.ap.bot_service.list_event_route_statuses(bot_uuid))
|
||||
|
||||
async def _dry_run_event_route(bot_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
if not isinstance(json_data, dict):
|
||||
|
||||
@@ -20,6 +20,7 @@ class BotService:
|
||||
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
|
||||
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
|
||||
FAILURE_INVALID_EVENT = 'invalid_event'
|
||||
ROUTE_TRACE_KIND = 'event_route_trace'
|
||||
|
||||
BOT_FIELDS = {
|
||||
'uuid',
|
||||
@@ -103,6 +104,41 @@ class BotService:
|
||||
def _format_diagnostic_steps(cls, diagnostic_details: list[dict[str, typing.Any]] | None) -> list[str]:
|
||||
return [cls._format_diagnostic_step(step) for step in diagnostic_details or []]
|
||||
|
||||
@classmethod
|
||||
def _event_route_status_from_log(cls, log: typing.Any) -> dict[str, typing.Any] | None:
|
||||
if hasattr(log, 'to_json'):
|
||||
log_data = log.to_json()
|
||||
elif isinstance(log, dict):
|
||||
log_data = log
|
||||
else:
|
||||
log_data = {
|
||||
'seq_id': getattr(log, 'seq_id', None),
|
||||
'timestamp': getattr(log, 'timestamp', None),
|
||||
'level': getattr(getattr(log, 'level', None), 'value', getattr(log, 'level', None)),
|
||||
'text': getattr(log, 'text', None),
|
||||
'metadata': getattr(log, 'metadata', None),
|
||||
}
|
||||
|
||||
metadata = log_data.get('metadata')
|
||||
if not isinstance(metadata, dict) or metadata.get('kind') != cls.ROUTE_TRACE_KIND:
|
||||
return None
|
||||
|
||||
return {
|
||||
'binding_id': metadata.get('binding_id'),
|
||||
'event_pattern': metadata.get('event_pattern'),
|
||||
'event_type': metadata.get('event_type'),
|
||||
'target_type': metadata.get('target_type'),
|
||||
'target_uuid': metadata.get('target_uuid') or '',
|
||||
'last_status': metadata.get('status'),
|
||||
'failure_code': metadata.get('failure_code'),
|
||||
'reason': metadata.get('reason') or log_data.get('text') or '',
|
||||
'run_id': metadata.get('run_id'),
|
||||
'timestamp': log_data.get('timestamp'),
|
||||
'seq_id': log_data.get('seq_id'),
|
||||
'level': log_data.get('level'),
|
||||
'message': log_data.get('text') or '',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _target_kind(target_type: typing.Any, target_kind: str | None = None) -> str | None:
|
||||
if target_kind:
|
||||
@@ -644,6 +680,71 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def list_event_route_statuses(self, bot_uuid: str) -> dict[str, typing.Any]:
|
||||
"""Return recent runtime status for Bot event routes from in-memory Bot logs."""
|
||||
from ....platform.botmgr import RuntimeBot
|
||||
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
raise Exception('Bot not found')
|
||||
|
||||
latest_by_binding: dict[str, dict[str, typing.Any]] = {}
|
||||
unmatched_events: list[dict[str, typing.Any]] = []
|
||||
for log in getattr(runtime_bot.logger, 'logs', []):
|
||||
status = self._event_route_status_from_log(log)
|
||||
if status is None:
|
||||
continue
|
||||
binding_id = status.get('binding_id')
|
||||
if binding_id:
|
||||
latest_by_binding[str(binding_id)] = status
|
||||
else:
|
||||
unmatched_events.append(status)
|
||||
|
||||
raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', [])
|
||||
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
|
||||
routes: list[dict[str, typing.Any]] = []
|
||||
current_binding_ids: set[str] = set()
|
||||
for index, binding in enumerate(bindings):
|
||||
binding_id = binding.get('id')
|
||||
if binding_id:
|
||||
current_binding_ids.add(str(binding_id))
|
||||
route_status = {
|
||||
'binding_id': binding_id,
|
||||
'event_pattern': binding.get('event_pattern'),
|
||||
'event_type': None,
|
||||
'target_type': binding.get('target_type'),
|
||||
'target_uuid': binding.get('target_uuid') or '',
|
||||
'last_status': None,
|
||||
'failure_code': None,
|
||||
'reason': None,
|
||||
'run_id': None,
|
||||
'timestamp': None,
|
||||
'seq_id': None,
|
||||
'level': None,
|
||||
'message': '',
|
||||
'order': binding.get('order', index),
|
||||
'enabled': binding.get('enabled', True),
|
||||
'current': True,
|
||||
}
|
||||
if binding_id and str(binding_id) in latest_by_binding:
|
||||
route_status.update(latest_by_binding[str(binding_id)])
|
||||
route_status['order'] = binding.get('order', index)
|
||||
route_status['enabled'] = binding.get('enabled', True)
|
||||
route_status['current'] = True
|
||||
routes.append(route_status)
|
||||
|
||||
stale_routes = [
|
||||
{**status, 'current': False}
|
||||
for binding_id, status in latest_by_binding.items()
|
||||
if binding_id not in current_binding_ids
|
||||
]
|
||||
|
||||
return {
|
||||
'routes': routes,
|
||||
'unmatched_events': unmatched_events[-10:],
|
||||
'stale_routes': stale_routes,
|
||||
}
|
||||
|
||||
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
|
||||
"""Send message to a specific target via bot
|
||||
|
||||
|
||||
@@ -305,6 +305,37 @@ class RuntimeBot:
|
||||
"""Return the selected event binding plus per-binding diagnostic steps."""
|
||||
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
||||
|
||||
async def _record_event_route_trace(
|
||||
self,
|
||||
*,
|
||||
event_type: str,
|
||||
status: str,
|
||||
text: str,
|
||||
level: str = 'info',
|
||||
binding: dict[str, typing.Any] | None = None,
|
||||
target_type: str | None = None,
|
||||
target_uuid: str | None = None,
|
||||
failure_code: str | None = None,
|
||||
reason: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record structured event routing state while preserving the human log."""
|
||||
binding = binding or {}
|
||||
metadata = {
|
||||
'kind': 'event_route_trace',
|
||||
'event_type': event_type,
|
||||
'status': status,
|
||||
'binding_id': binding.get('id'),
|
||||
'event_pattern': binding.get('event_pattern'),
|
||||
'target_type': target_type or binding.get('target_type'),
|
||||
'target_uuid': target_uuid or binding.get('target_uuid') or '',
|
||||
'failure_code': failure_code,
|
||||
'reason': reason or text,
|
||||
'run_id': run_id,
|
||||
}
|
||||
log_method = getattr(self.logger, level, self.logger.info)
|
||||
await log_method(text, metadata=metadata)
|
||||
|
||||
def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None:
|
||||
"""Return the first Pipeline target configured for an event type."""
|
||||
matched: list[tuple[int, int, str]] = []
|
||||
@@ -773,10 +804,24 @@ class RuntimeBot:
|
||||
|
||||
event_binding = self._resolve_eba_event_binding(event, event_type)
|
||||
if event_binding is None:
|
||||
await self.logger.info(f'Platform event {event_type} ignored: no event route matched')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='not_matched',
|
||||
failure_code='route_not_found',
|
||||
reason='No event route matched',
|
||||
text=f'Platform event {event_type} ignored: no event route matched',
|
||||
)
|
||||
return
|
||||
|
||||
target_type = event_binding.get('target_type')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='matched',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
text=f'EBA event {event_type} matched route {event_binding.get("id") or ""}'.strip(),
|
||||
)
|
||||
if target_type == 'discard':
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
await self._dispatch_eba_message_to_pipeline(
|
||||
@@ -785,12 +830,35 @@ class RuntimeBot:
|
||||
pipeline_uuid=self.PIPELINE_DISCARD,
|
||||
routed_by_event_binding=True,
|
||||
)
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='discarded',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
text=f'EBA event {event_type} discarded by event binding',
|
||||
)
|
||||
return
|
||||
await self.logger.info(f'EBA event {event_type} discarded by event binding')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='discarded',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
text=f'EBA event {event_type} discarded by event binding',
|
||||
)
|
||||
return
|
||||
if target_type == 'pipeline':
|
||||
if not self._is_message_event_type(event_type):
|
||||
await self.logger.warning(f'EBA event {event_type} ignored Pipeline target for non-message event')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='warning',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
failure_code='processor_incompatible',
|
||||
reason='Pipeline targets only support message events',
|
||||
text=f'EBA event {event_type} ignored Pipeline target for non-message event',
|
||||
)
|
||||
return
|
||||
await self._dispatch_eba_message_to_pipeline(
|
||||
event,
|
||||
@@ -798,26 +866,82 @@ class RuntimeBot:
|
||||
pipeline_uuid=event_binding.get('target_uuid'),
|
||||
routed_by_event_binding=True,
|
||||
)
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='delivered',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
|
||||
)
|
||||
return
|
||||
if target_type != 'agent':
|
||||
await self.logger.warning(f'EBA event {event_type} ignored unsupported target type {target_type}')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='warning',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
failure_code='processor_incompatible',
|
||||
reason=f'Unsupported event binding target type: {target_type}',
|
||||
text=f'EBA event {event_type} ignored unsupported target type {target_type}',
|
||||
)
|
||||
return
|
||||
|
||||
target_uuid = event_binding.get('target_uuid')
|
||||
agent = await self.ap.agent_service.get_agent(target_uuid)
|
||||
if not agent or agent.get('kind') != 'agent':
|
||||
await self.logger.warning(f'EBA event {event_type} target agent not found: {target_uuid}')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='warning',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_not_found',
|
||||
reason='Agent target not found',
|
||||
text=f'EBA event {event_type} target agent not found: {target_uuid}',
|
||||
)
|
||||
return
|
||||
if not agent.get('enabled', True):
|
||||
await self.logger.info(f'EBA event {event_type} target agent disabled: {target_uuid}')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_disabled',
|
||||
reason='Agent target is disabled',
|
||||
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
|
||||
)
|
||||
return
|
||||
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||||
await self.logger.info(f'EBA event {event_type} target agent does not support this event: {target_uuid}')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_incompatible',
|
||||
reason='Agent target does not support this event type',
|
||||
text=f'EBA event {event_type} target agent does not support this event: {target_uuid}',
|
||||
)
|
||||
return
|
||||
|
||||
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
|
||||
if binding is None:
|
||||
await self.logger.warning(f'EBA event {event_type} target agent has no runner: {target_uuid}')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='warning',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_not_found',
|
||||
reason='Agent target has no runner',
|
||||
text=f'EBA event {event_type} target agent has no runner: {target_uuid}',
|
||||
)
|
||||
return
|
||||
|
||||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||
@@ -826,15 +950,42 @@ class RuntimeBot:
|
||||
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
|
||||
outputs.append(output)
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}')
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='error',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='runner_failed',
|
||||
reason='Agent runner failed',
|
||||
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await self._deliver_agent_outputs(envelope, outputs)
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}'
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='error',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='delivery_failed',
|
||||
reason='Agent output delivery failed',
|
||||
text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}',
|
||||
)
|
||||
return
|
||||
await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='delivered',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
text=f'EBA event {event_type} delivered to Agent {target_uuid}',
|
||||
)
|
||||
|
||||
async def _record_discarded_message(
|
||||
self,
|
||||
|
||||
@@ -41,6 +41,9 @@ class EventLog(pydantic.BaseModel):
|
||||
message_session_id: typing.Optional[str] = None
|
||||
"""消息会话ID,仅收发消息事件有值"""
|
||||
|
||||
metadata: typing.Optional[dict[str, typing.Any]] = None
|
||||
"""Structured machine-readable metadata for product surfaces."""
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
'seq_id': self.seq_id,
|
||||
@@ -49,6 +52,7 @@ class EventLog(pydantic.BaseModel):
|
||||
'text': self.text,
|
||||
'images': self.images,
|
||||
'message_session_id': self.message_session_id,
|
||||
'metadata': self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +135,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images: typing.Optional[list[platform_message.Image]] = None,
|
||||
message_session_id: typing.Optional[str] = None,
|
||||
no_throw: bool = True,
|
||||
metadata: typing.Optional[dict[str, typing.Any]] = None,
|
||||
):
|
||||
try:
|
||||
image_keys = []
|
||||
@@ -161,6 +166,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
text=text,
|
||||
images=image_keys,
|
||||
message_session_id=message_session_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self.seq_id_inc += 1
|
||||
@@ -179,6 +185,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images: typing.Optional[list[platform_message.Image]] = None,
|
||||
message_session_id: typing.Optional[str] = None,
|
||||
no_throw: bool = True,
|
||||
metadata: typing.Optional[dict[str, typing.Any]] = None,
|
||||
):
|
||||
await self._add_log(
|
||||
level=EventLogLevel.INFO,
|
||||
@@ -186,6 +193,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images=images,
|
||||
message_session_id=message_session_id,
|
||||
no_throw=no_throw,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def debug(
|
||||
@@ -194,6 +202,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images: typing.Optional[list[platform_message.Image]] = None,
|
||||
message_session_id: typing.Optional[str] = None,
|
||||
no_throw: bool = True,
|
||||
metadata: typing.Optional[dict[str, typing.Any]] = None,
|
||||
):
|
||||
await self._add_log(
|
||||
level=EventLogLevel.DEBUG,
|
||||
@@ -201,6 +210,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images=images,
|
||||
message_session_id=message_session_id,
|
||||
no_throw=no_throw,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def warning(
|
||||
@@ -209,6 +219,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images: typing.Optional[list[platform_message.Image]] = None,
|
||||
message_session_id: typing.Optional[str] = None,
|
||||
no_throw: bool = True,
|
||||
metadata: typing.Optional[dict[str, typing.Any]] = None,
|
||||
):
|
||||
await self._add_log(
|
||||
level=EventLogLevel.WARNING,
|
||||
@@ -216,6 +227,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images=images,
|
||||
message_session_id=message_session_id,
|
||||
no_throw=no_throw,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def error(
|
||||
@@ -224,6 +236,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images: typing.Optional[list[platform_message.Image]] = None,
|
||||
message_session_id: typing.Optional[str] = None,
|
||||
no_throw: bool = True,
|
||||
metadata: typing.Optional[dict[str, typing.Any]] = None,
|
||||
):
|
||||
await self._add_log(
|
||||
level=EventLogLevel.ERROR,
|
||||
@@ -231,4 +244,5 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
images=images,
|
||||
message_session_id=message_session_id,
|
||||
no_throw=no_throw,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@@ -97,6 +97,27 @@ def fake_bot_app():
|
||||
app.bot_service.update_bot = AsyncMock(return_value={})
|
||||
app.bot_service.delete_bot = AsyncMock()
|
||||
app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1))
|
||||
app.bot_service.list_event_route_statuses = AsyncMock(
|
||||
return_value={
|
||||
'routes': [
|
||||
{
|
||||
'binding_id': 'binding-1',
|
||||
'event_pattern': 'platform.member.joined',
|
||||
'event_type': 'platform.member.joined',
|
||||
'target_type': 'agent',
|
||||
'target_uuid': 'agent-1',
|
||||
'last_status': 'delivered',
|
||||
'failure_code': None,
|
||||
'reason': 'delivered',
|
||||
'timestamp': 100,
|
||||
'seq_id': 1,
|
||||
'current': True,
|
||||
}
|
||||
],
|
||||
'unmatched_events': [],
|
||||
'stale_routes': [],
|
||||
}
|
||||
)
|
||||
app.bot_service.dry_run_event_route = AsyncMock(
|
||||
return_value={
|
||||
'matched': True,
|
||||
@@ -268,6 +289,26 @@ class TestBotEventRouteDryRunEndpoint:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestBotEventRouteStatusEndpoint:
|
||||
"""Tests for bot event route runtime status endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_route_status_success(self, quart_test_client, fake_bot_app):
|
||||
"""GET event route status returns recent route traces."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/platform/bots/test-bot-uuid/event-routes/status',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['routes'][0]['binding_id'] == 'binding-1'
|
||||
assert data['data']['routes'][0]['last_status'] == 'delivered'
|
||||
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid')
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestBotSendMessageEndpoint:
|
||||
"""Tests for bot send message endpoint."""
|
||||
|
||||
@@ -997,6 +997,134 @@ class TestBotServiceListEventLogs:
|
||||
assert total == 5
|
||||
|
||||
|
||||
class TestBotServiceEventRouteStatuses:
|
||||
"""Tests for event route runtime status aggregation."""
|
||||
|
||||
async def test_list_event_route_statuses_bot_not_found_raises(self):
|
||||
"""Raises Exception when runtime bot not found."""
|
||||
ap = SimpleNamespace()
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
with pytest.raises(Exception, match='Bot not found'):
|
||||
await service.list_event_route_statuses('missing-bot')
|
||||
|
||||
async def test_list_event_route_statuses_merges_latest_trace_by_binding(self):
|
||||
"""Current route definitions are enriched with the latest trace log."""
|
||||
ap = SimpleNamespace()
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
|
||||
runtime_bot = SimpleNamespace()
|
||||
runtime_bot.bot_entity = SimpleNamespace(
|
||||
event_bindings=[
|
||||
{
|
||||
'id': 'binding-1',
|
||||
'event_pattern': 'platform.member.joined',
|
||||
'target_type': 'agent',
|
||||
'target_uuid': 'agent-1',
|
||||
'enabled': True,
|
||||
'order': 0,
|
||||
},
|
||||
{
|
||||
'id': 'binding-2',
|
||||
'event_pattern': 'message.received',
|
||||
'target_type': 'pipeline',
|
||||
'target_uuid': 'pipeline-1',
|
||||
'enabled': False,
|
||||
'order': 1,
|
||||
},
|
||||
]
|
||||
)
|
||||
runtime_bot.logger = SimpleNamespace(
|
||||
logs=[
|
||||
SimpleNamespace(
|
||||
to_json=Mock(
|
||||
return_value={
|
||||
'seq_id': 1,
|
||||
'timestamp': 100,
|
||||
'level': 'info',
|
||||
'text': 'old matched',
|
||||
'metadata': {
|
||||
'kind': 'event_route_trace',
|
||||
'binding_id': 'binding-1',
|
||||
'event_pattern': 'platform.member.joined',
|
||||
'event_type': 'platform.member.joined',
|
||||
'target_type': 'agent',
|
||||
'target_uuid': 'agent-1',
|
||||
'status': 'matched',
|
||||
'failure_code': None,
|
||||
'reason': 'matched',
|
||||
'run_id': None,
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
SimpleNamespace(
|
||||
to_json=Mock(
|
||||
return_value={
|
||||
'seq_id': 2,
|
||||
'timestamp': 120,
|
||||
'level': 'error',
|
||||
'text': 'runner failed',
|
||||
'metadata': {
|
||||
'kind': 'event_route_trace',
|
||||
'binding_id': 'binding-1',
|
||||
'event_pattern': 'platform.member.joined',
|
||||
'event_type': 'platform.member.joined',
|
||||
'target_type': 'agent',
|
||||
'target_uuid': 'agent-1',
|
||||
'status': 'failed',
|
||||
'failure_code': 'runner_failed',
|
||||
'reason': 'Agent runner failed',
|
||||
'run_id': None,
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
SimpleNamespace(
|
||||
to_json=Mock(
|
||||
return_value={
|
||||
'seq_id': 3,
|
||||
'timestamp': 130,
|
||||
'level': 'info',
|
||||
'text': 'no route',
|
||||
'metadata': {
|
||||
'kind': 'event_route_trace',
|
||||
'binding_id': None,
|
||||
'event_pattern': None,
|
||||
'event_type': 'platform.member.left',
|
||||
'target_type': None,
|
||||
'target_uuid': '',
|
||||
'status': 'not_matched',
|
||||
'failure_code': 'route_not_found',
|
||||
'reason': 'No event route matched',
|
||||
'run_id': None,
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
|
||||
|
||||
service = BotService(ap)
|
||||
result = await service.list_event_route_statuses('bot-1')
|
||||
|
||||
assert len(result['routes']) == 2
|
||||
assert result['routes'][0]['binding_id'] == 'binding-1'
|
||||
assert result['routes'][0]['last_status'] == 'failed'
|
||||
assert result['routes'][0]['failure_code'] == 'runner_failed'
|
||||
assert result['routes'][0]['timestamp'] == 120
|
||||
assert result['routes'][0]['current'] is True
|
||||
assert result['routes'][1]['binding_id'] == 'binding-2'
|
||||
assert result['routes'][1]['last_status'] is None
|
||||
assert result['routes'][1]['enabled'] is False
|
||||
assert result['unmatched_events'][0]['event_type'] == 'platform.member.left'
|
||||
assert result['unmatched_events'][0]['failure_code'] == 'route_not_found'
|
||||
|
||||
|
||||
class TestBotServiceSendMessage:
|
||||
"""Tests for send_message method."""
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestMatchOperator:
|
||||
@@ -54,6 +56,90 @@ class TestMatchOperator:
|
||||
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
|
||||
|
||||
|
||||
class TestEventRouteTrace:
|
||||
"""Test structured event route trace logging."""
|
||||
|
||||
@staticmethod
|
||||
def _make_bot(event_bindings: list[dict]):
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(uuid='bot-1', event_bindings=event_bindings)
|
||||
bot.logger = SimpleNamespace(
|
||||
info=AsyncMock(),
|
||||
warning=AsyncMock(),
|
||||
error=AsyncMock(),
|
||||
)
|
||||
return bot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_no_matching_route_records_trace(self):
|
||||
"""A route miss is visible as structured route trace metadata."""
|
||||
bot = self._make_bot([])
|
||||
|
||||
await bot._dispatch_eba_event_to_agent(SimpleNamespace(type='platform.member.joined'), Mock())
|
||||
|
||||
bot.logger.info.assert_awaited_once()
|
||||
_, kwargs = bot.logger.info.await_args
|
||||
metadata = kwargs['metadata']
|
||||
assert metadata['kind'] == 'event_route_trace'
|
||||
assert metadata['event_type'] == 'platform.member.joined'
|
||||
assert metadata['status'] == 'not_matched'
|
||||
assert metadata['failure_code'] == 'route_not_found'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_event_route_trace_includes_binding_and_target(self):
|
||||
"""Trace metadata preserves binding and processor identifiers."""
|
||||
bot = self._make_bot([])
|
||||
|
||||
await bot._record_event_route_trace(
|
||||
event_type='platform.member.joined',
|
||||
status='failed',
|
||||
level='warning',
|
||||
binding={
|
||||
'id': 'binding-1',
|
||||
'event_pattern': 'platform.member.*',
|
||||
'target_type': 'agent',
|
||||
'target_uuid': 'agent-1',
|
||||
},
|
||||
failure_code='processor_disabled',
|
||||
reason='Agent target is disabled',
|
||||
text='disabled',
|
||||
)
|
||||
|
||||
bot.logger.warning.assert_awaited_once()
|
||||
_, kwargs = bot.logger.warning.await_args
|
||||
metadata = kwargs['metadata']
|
||||
assert metadata['binding_id'] == 'binding-1'
|
||||
assert metadata['event_pattern'] == 'platform.member.*'
|
||||
assert metadata['target_type'] == 'agent'
|
||||
assert metadata['target_uuid'] == 'agent-1'
|
||||
assert metadata['status'] == 'failed'
|
||||
|
||||
|
||||
class TestEventLoggerMetadata:
|
||||
"""Test platform EventLogger metadata compatibility."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_is_serialized_without_breaking_no_throw_position(self):
|
||||
"""Metadata is optional and no_throw remains the fourth positional argument."""
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
logger = EventLogger(name='test', ap=SimpleNamespace())
|
||||
|
||||
await logger.info('plain log', None, None, False)
|
||||
await logger.info(
|
||||
'route trace',
|
||||
metadata={'kind': 'event_route_trace', 'status': 'matched'},
|
||||
)
|
||||
|
||||
assert logger.logs[0].to_json()['metadata'] is None
|
||||
assert logger.logs[1].to_json()['metadata'] == {
|
||||
'kind': 'event_route_trace',
|
||||
'status': 'matched',
|
||||
}
|
||||
|
||||
|
||||
class TestResolvePipelineUuid:
|
||||
"""Test the resolve_pipeline_uuid method."""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ListChecks,
|
||||
Plus,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Workflow,
|
||||
XCircle,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
EventBinding,
|
||||
Agent,
|
||||
BotRouteDryRunResult,
|
||||
BotEventRouteStatus,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
@@ -213,6 +215,36 @@ function targetTypeLabel(
|
||||
return t('bots.targetAgent');
|
||||
}
|
||||
|
||||
function routeStatusLabel(
|
||||
status: BotEventRouteStatus['last_status'] | undefined,
|
||||
t: TFunction,
|
||||
) {
|
||||
if (!status) return t('bots.routeStatusIdle');
|
||||
const key = `bots.routeStatus.${status}`;
|
||||
const label = t(key);
|
||||
return label === key ? String(status) : label;
|
||||
}
|
||||
|
||||
function routeStatusBadgeClass(
|
||||
status: BotEventRouteStatus['last_status'] | undefined,
|
||||
) {
|
||||
if (status === 'delivered') {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-300';
|
||||
}
|
||||
if (status === 'matched' || status === 'discarded') {
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/30 dark:text-sky-300';
|
||||
}
|
||||
if (status === 'failed' || status === 'not_matched') {
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300';
|
||||
}
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
|
||||
function formatRouteStatusTime(timestamp: number | null | undefined) {
|
||||
if (!timestamp) return '';
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
// ── target combobox (type + target merged, with search + groups) ───────────────
|
||||
|
||||
// Encoded value: "discard", "agent:<uuid>", "pipeline:<uuid>"
|
||||
@@ -843,6 +875,7 @@ function RouteDryRunDialog({
|
||||
interface BindingCardProps {
|
||||
binding: EventBinding;
|
||||
globalIndex: number;
|
||||
routeStatus?: BotEventRouteStatus;
|
||||
eventOptions: string[];
|
||||
agentOptions: Agent[];
|
||||
expandedIds: Set<string>;
|
||||
@@ -856,6 +889,7 @@ interface BindingCardProps {
|
||||
function BindingCardContent({
|
||||
binding,
|
||||
globalIndex,
|
||||
routeStatus,
|
||||
eventOptions,
|
||||
agentOptions,
|
||||
expandedIds,
|
||||
@@ -870,6 +904,7 @@ function BindingCardContent({
|
||||
const isExpanded = expandedIds.has(id);
|
||||
const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0;
|
||||
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
||||
const statusTime = formatRouteStatusTime(routeStatus?.timestamp);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
@@ -956,6 +991,23 @@ function BindingCardContent({
|
||||
onUpdate={(patch) => onUpdate(globalIndex, patch)}
|
||||
/>
|
||||
|
||||
<div className="hidden min-w-[132px] max-w-[220px] flex-col items-end gap-1 lg:flex">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${routeStatusBadgeClass(
|
||||
routeStatus?.last_status,
|
||||
)}`}
|
||||
>
|
||||
{routeStatusLabel(routeStatus?.last_status, t)}
|
||||
</Badge>
|
||||
<span
|
||||
className="max-w-full truncate text-[11px] text-muted-foreground"
|
||||
title={routeStatus?.reason || routeStatus?.message || statusTime}
|
||||
>
|
||||
{routeStatus?.failure_code || routeStatus?.reason || statusTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!pipelineAllowed && binding.target_type === 'pipeline' && (
|
||||
<span className="text-xs text-destructive shrink-0">
|
||||
{t('bots.unsupportedPipelineEvent')}
|
||||
@@ -984,6 +1036,23 @@ function BindingCardContent({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t px-3 py-1.5 lg:hidden">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${routeStatusBadgeClass(
|
||||
routeStatus?.last_status,
|
||||
)}`}
|
||||
>
|
||||
{routeStatusLabel(routeStatus?.last_status, t)}
|
||||
</Badge>
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] text-muted-foreground"
|
||||
title={routeStatus?.reason || routeStatus?.message || statusTime}
|
||||
>
|
||||
{routeStatus?.failure_code || routeStatus?.reason || statusTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* conditions panel */}
|
||||
{isExpanded && (
|
||||
<div className="border-t px-3 py-2.5">
|
||||
@@ -1035,6 +1104,9 @@ export default function EventBindingsEditor({
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [disabledSectionOpen, setDisabledSectionOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [routeStatuses, setRouteStatuses] = useState<BotEventRouteStatus[]>([]);
|
||||
const [routeStatusLoading, setRouteStatusLoading] = useState(false);
|
||||
const [routeStatusError, setRouteStatusError] = useState<string | null>(null);
|
||||
|
||||
// stable ids for dnd
|
||||
const nextId = useRef(0);
|
||||
@@ -1058,6 +1130,36 @@ export default function EventBindingsEditor({
|
||||
() => (supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS),
|
||||
[supportedEvents],
|
||||
);
|
||||
const routeStatusByBinding = useMemo(() => {
|
||||
const map = new Map<string, BotEventRouteStatus>();
|
||||
routeStatuses.forEach((status) => {
|
||||
if (status.binding_id) map.set(String(status.binding_id), status);
|
||||
});
|
||||
return map;
|
||||
}, [routeStatuses]);
|
||||
|
||||
const refreshRouteStatuses = useCallback(async () => {
|
||||
if (!botId) {
|
||||
setRouteStatuses([]);
|
||||
setRouteStatusError(null);
|
||||
return;
|
||||
}
|
||||
setRouteStatusLoading(true);
|
||||
setRouteStatusError(null);
|
||||
try {
|
||||
const response = await backendClient.getBotEventRouteStatuses(botId);
|
||||
setRouteStatuses(response.routes || []);
|
||||
} catch (error) {
|
||||
const err = error as { msg?: string };
|
||||
setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed'));
|
||||
} finally {
|
||||
setRouteStatusLoading(false);
|
||||
}
|
||||
}, [botId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRouteStatuses();
|
||||
}, [refreshRouteStatuses]);
|
||||
|
||||
function updateBindings(next: EventBinding[]) {
|
||||
form.setValue('event_bindings', next, { shouldDirty: true });
|
||||
@@ -1175,6 +1277,11 @@ export default function EventBindingsEditor({
|
||||
key={idsRef.current[sortIdx]}
|
||||
binding={binding}
|
||||
globalIndex={globalIdx}
|
||||
routeStatus={
|
||||
binding.id
|
||||
? routeStatusByBinding.get(String(binding.id))
|
||||
: undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
@@ -1191,6 +1298,11 @@ export default function EventBindingsEditor({
|
||||
<BindingCardContent
|
||||
binding={activeBinding}
|
||||
globalIndex={activeGlobalIdx}
|
||||
routeStatus={
|
||||
activeBinding.id
|
||||
? routeStatusByBinding.get(String(activeBinding.id))
|
||||
: undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
@@ -1214,7 +1326,22 @@ export default function EventBindingsEditor({
|
||||
eventOptions={dryRunEventOptions}
|
||||
agentOptions={agentOptions}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refreshRouteStatuses}
|
||||
disabled={!botId || routeStatusLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-1 ${routeStatusLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('bots.refreshRouteStatus')}
|
||||
</Button>
|
||||
</div>
|
||||
{routeStatusError && (
|
||||
<p className="text-xs text-destructive">{routeStatusError}</p>
|
||||
)}
|
||||
|
||||
{/* disabled section */}
|
||||
{disabledBindings.length > 0 && (
|
||||
@@ -1241,6 +1368,9 @@ export default function EventBindingsEditor({
|
||||
key={b.id ?? i}
|
||||
binding={b}
|
||||
globalIndex={i}
|
||||
routeStatus={
|
||||
b.id ? routeStatusByBinding.get(String(b.id)) : undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
|
||||
@@ -274,6 +274,38 @@ export interface BotRouteDryRunResult {
|
||||
matched_binding_index?: number | null;
|
||||
}
|
||||
|
||||
export interface BotEventRouteStatus {
|
||||
binding_id?: string | null;
|
||||
event_pattern?: string | null;
|
||||
event_type?: string | null;
|
||||
target_type?: EventBinding['target_type'] | string | null;
|
||||
target_uuid?: string | null;
|
||||
last_status?:
|
||||
| 'matched'
|
||||
| 'delivered'
|
||||
| 'discarded'
|
||||
| 'failed'
|
||||
| 'not_matched'
|
||||
| string
|
||||
| null;
|
||||
failure_code?: string | null;
|
||||
reason?: string | null;
|
||||
run_id?: string | null;
|
||||
timestamp?: number | null;
|
||||
seq_id?: number | null;
|
||||
level?: string | null;
|
||||
message?: string | null;
|
||||
order?: number | null;
|
||||
enabled?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface BotEventRouteStatusResponse {
|
||||
routes: BotEventRouteStatus[];
|
||||
unmatched_events: BotEventRouteStatus[];
|
||||
stale_routes: BotEventRouteStatus[];
|
||||
}
|
||||
|
||||
export interface ApiRespKnowledgeBases {
|
||||
bases: KnowledgeBase[];
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
ApiRespSkill,
|
||||
BotRouteDryRunRequest,
|
||||
BotRouteDryRunResult,
|
||||
BotEventRouteStatusResponse,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||
@@ -466,6 +467,12 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public getBotEventRouteStatuses(
|
||||
botId: string,
|
||||
): Promise<BotEventRouteStatusResponse> {
|
||||
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
||||
}
|
||||
|
||||
public deleteBot(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
||||
}
|
||||
|
||||
@@ -392,6 +392,16 @@ const enUS = {
|
||||
advancedEventValues: 'Advanced event values',
|
||||
eventGroup: 'Group',
|
||||
testRoute: 'Test route',
|
||||
refreshRouteStatus: 'Refresh status',
|
||||
routeStatusIdle: 'No run yet',
|
||||
routeStatusRefreshFailed: 'Failed to refresh route status.',
|
||||
routeStatus: {
|
||||
matched: 'Matched',
|
||||
delivered: 'Delivered',
|
||||
discarded: 'Discarded',
|
||||
failed: 'Failed',
|
||||
not_matched: 'Not matched',
|
||||
},
|
||||
dryRunTitle: 'Test event route',
|
||||
dryRunDescription:
|
||||
'Choose an event and provide a sample payload to see which processor the current route setup will use.',
|
||||
|
||||
@@ -399,6 +399,16 @@ const jaJP = {
|
||||
advancedEventValues: '高度なイベント値',
|
||||
eventGroup: 'グループ',
|
||||
testRoute: 'ルートをテスト',
|
||||
refreshRouteStatus: '状態を更新',
|
||||
routeStatusIdle: '実行記録なし',
|
||||
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
||||
routeStatus: {
|
||||
matched: '一致',
|
||||
delivered: '配信済み',
|
||||
discarded: '破棄済み',
|
||||
failed: '失敗',
|
||||
not_matched: '未一致',
|
||||
},
|
||||
dryRunTitle: 'イベントルートをテスト',
|
||||
dryRunDescription:
|
||||
'イベントとサンプルペイロードを指定し、現在のルート設定でどのプロセッサーに送られるか確認します。',
|
||||
|
||||
@@ -377,6 +377,16 @@ const zhHans = {
|
||||
advancedEventValues: '高级事件值',
|
||||
eventGroup: '事件组',
|
||||
testRoute: '测试路由',
|
||||
refreshRouteStatus: '刷新状态',
|
||||
routeStatusIdle: '暂无运行记录',
|
||||
routeStatusRefreshFailed: '刷新路由状态失败。',
|
||||
routeStatus: {
|
||||
matched: '已命中',
|
||||
delivered: '已投递',
|
||||
discarded: '已丢弃',
|
||||
failed: '失败',
|
||||
not_matched: '未命中',
|
||||
},
|
||||
dryRunTitle: '测试事件路由',
|
||||
dryRunDescription:
|
||||
'选择一个事件并提供示例载荷,检查当前路由配置会命中哪个处理器。',
|
||||
|
||||
Reference in New Issue
Block a user