feat(runtime-activity): live feed and activity correlation (batch 4, #33)
This commit is contained in:
parent
1a847133ce
commit
16b23eef2e
|
|
@ -2,11 +2,14 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlmodel import col
|
from sqlmodel import col
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.api.deps import require_org_admin, require_org_member
|
from app.api.deps import require_org_admin, require_org_member
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
|
|
@ -33,6 +36,11 @@ from app.schemas.runtime_usage import (
|
||||||
ProviderUsageScrapeResult,
|
ProviderUsageScrapeResult,
|
||||||
RuntimeUsageResponse,
|
RuntimeUsageResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.openclaw.runtime_activity import (
|
||||||
|
HISTORY_FETCH_LIMIT,
|
||||||
|
POLL_HISTORY_SESSIONS_MAX,
|
||||||
|
fetch_recent_events,
|
||||||
|
)
|
||||||
from app.services.openclaw.runtime_usage import get_runtime_usage
|
from app.services.openclaw.runtime_usage import get_runtime_usage
|
||||||
from app.services.openclaw.usage_scrapers import get_provider_usage
|
from app.services.openclaw.usage_scrapers import get_provider_usage
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
|
|
@ -352,6 +360,115 @@ async def get_gateway_provider_usage(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{gateway_id}/runtime-activity",
|
||||||
|
summary="Recent gateway runtime messages (REST snapshot)",
|
||||||
|
description=(
|
||||||
|
"Return the most recent messages from all active sessions on the gateway, "
|
||||||
|
"normalised and redacted. Use the streaming endpoint for a live feed."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_gateway_runtime_activity(
|
||||||
|
gateway_id: UUID,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> dict:
|
||||||
|
"""Non-streaming snapshot of recent gateway activity."""
|
||||||
|
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||||
|
|
||||||
|
service = GatewayAdminLifecycleService(session)
|
||||||
|
gateway = await service.require_gateway(
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
)
|
||||||
|
config = GatewayClientConfig(
|
||||||
|
url=gateway.url,
|
||||||
|
token=gateway.token,
|
||||||
|
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||||
|
disable_device_pairing=gateway.disable_device_pairing,
|
||||||
|
)
|
||||||
|
events = await fetch_recent_events(
|
||||||
|
config,
|
||||||
|
max_sessions=POLL_HISTORY_SESSIONS_MAX,
|
||||||
|
history_limit=HISTORY_FETCH_LIMIT,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"gateway_id": str(gateway_id),
|
||||||
|
"generated_at": utcnow().isoformat(),
|
||||||
|
"events": [e.to_dict() for e in events],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_RUNTIME_ACTIVITY_POLL_SECONDS = 4
|
||||||
|
_RUNTIME_ACTIVITY_SEEN_MAX = 1000
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{gateway_id}/runtime-activity/stream",
|
||||||
|
summary="Live gateway runtime activity (SSE)",
|
||||||
|
description=(
|
||||||
|
"Stream normalised gateway session messages as server-sent events. "
|
||||||
|
"Each event is of type ``runtime_message``. The stream polls the "
|
||||||
|
"gateway every few seconds and emits only new messages."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def stream_gateway_runtime_activity(
|
||||||
|
request: Request,
|
||||||
|
gateway_id: UUID,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> EventSourceResponse:
|
||||||
|
"""SSE stream of live gateway session messages."""
|
||||||
|
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||||
|
|
||||||
|
service = GatewayAdminLifecycleService(session)
|
||||||
|
gateway = await service.require_gateway(
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
)
|
||||||
|
config = GatewayClientConfig(
|
||||||
|
url=gateway.url,
|
||||||
|
token=gateway.token,
|
||||||
|
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||||
|
disable_device_pairing=gateway.disable_device_pairing,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
from collections import deque
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
seen_queue: deque[str] = deque()
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
|
||||||
|
events = await fetch_recent_events(
|
||||||
|
config,
|
||||||
|
since_ids=None if initial else seen_ids,
|
||||||
|
max_sessions=POLL_HISTORY_SESSIONS_MAX,
|
||||||
|
history_limit=HISTORY_FETCH_LIMIT,
|
||||||
|
)
|
||||||
|
initial = False
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
if event.event_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(event.event_id)
|
||||||
|
seen_queue.append(event.event_id)
|
||||||
|
if len(seen_queue) > _RUNTIME_ACTIVITY_SEEN_MAX:
|
||||||
|
oldest = seen_queue.popleft()
|
||||||
|
seen_ids.discard(oldest)
|
||||||
|
yield {
|
||||||
|
"event": "runtime_message",
|
||||||
|
"data": json.dumps(event.to_dict()),
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncio.sleep(_RUNTIME_ACTIVITY_POLL_SECONDS)
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator(), ping=15)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{gateway_id}", response_model=OkResponse)
|
@router.delete("/{gateway_id}", response_model=OkResponse)
|
||||||
async def delete_gateway(
|
async def delete_gateway(
|
||||||
gateway_id: UUID,
|
gateway_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
"""Runtime activity service — fetch and normalize recent gateway session messages.
|
||||||
|
|
||||||
|
Data source: gateway ``chat.history`` RPC (returns recent messages per session).
|
||||||
|
This is supplemental to the DB-backed activity feed; it shows what is happening
|
||||||
|
in active gateway sessions in near-real-time without requiring any writes.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- Polling: callers poll this service on an interval; it does not maintain state.
|
||||||
|
- Deduplication: based on ``(session_key, message_index)`` because chat.history
|
||||||
|
does not return stable message IDs.
|
||||||
|
- Redaction: known-sensitive tool argument names are blanked; large content is
|
||||||
|
truncated to a short preview.
|
||||||
|
- Authorization: callers must have already verified gateway ownership before
|
||||||
|
passing a config here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.core.time import utcnow
|
||||||
|
from app.services.openclaw.gateway_rpc import (
|
||||||
|
GatewayConfig,
|
||||||
|
OpenClawGatewayError,
|
||||||
|
get_chat_history,
|
||||||
|
openclaw_call,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CONTENT_PREVIEW_MAX = 300 # chars before truncation
|
||||||
|
HISTORY_FETCH_LIMIT = 20 # messages to fetch per session per poll
|
||||||
|
POLL_HISTORY_SESSIONS_MAX = 10 # max sessions to poll in one pass
|
||||||
|
|
||||||
|
# Argument names that should be fully redacted from tool call previews
|
||||||
|
_REDACT_TOOL_ARG_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"password", "passwd", "secret", "token", "api_key", "apikey",
|
||||||
|
"access_key", "private_key", "credential", "credentials",
|
||||||
|
"authorization", "bearer", "session_token", "refresh_token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tool names whose entire input should be summarised rather than previewed
|
||||||
|
_SUMMARISE_TOOLS = frozenset({"bash", "computer", "str_replace_editor"})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Normalized event type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RuntimeMessageEvent:
|
||||||
|
"""Normalized representation of one gateway session message."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"event_id",
|
||||||
|
"session_key",
|
||||||
|
"session_label",
|
||||||
|
"role",
|
||||||
|
"model",
|
||||||
|
"content_preview",
|
||||||
|
"content_truncated",
|
||||||
|
"has_tool_use",
|
||||||
|
"tool_names",
|
||||||
|
"timestamp",
|
||||||
|
"agent_id",
|
||||||
|
"board_id",
|
||||||
|
"message_index",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
session_key: str,
|
||||||
|
session_label: str | None,
|
||||||
|
role: str,
|
||||||
|
model: str | None,
|
||||||
|
content_preview: str,
|
||||||
|
content_truncated: bool,
|
||||||
|
has_tool_use: bool,
|
||||||
|
tool_names: list[str],
|
||||||
|
timestamp: datetime | None,
|
||||||
|
agent_id: str | None,
|
||||||
|
board_id: str | None,
|
||||||
|
message_index: int,
|
||||||
|
) -> None:
|
||||||
|
self.event_id = event_id
|
||||||
|
self.session_key = session_key
|
||||||
|
self.session_label = session_label
|
||||||
|
self.role = role
|
||||||
|
self.model = model
|
||||||
|
self.content_preview = content_preview
|
||||||
|
self.content_truncated = content_truncated
|
||||||
|
self.has_tool_use = has_tool_use
|
||||||
|
self.tool_names = tool_names
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.agent_id = agent_id
|
||||||
|
self.board_id = board_id
|
||||||
|
self.message_index = message_index
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"session_key": self.session_key,
|
||||||
|
"session_label": self.session_label,
|
||||||
|
"role": self.role,
|
||||||
|
"model": self.model,
|
||||||
|
"content_preview": self.content_preview,
|
||||||
|
"content_truncated": self.content_truncated,
|
||||||
|
"has_tool_use": self.has_tool_use,
|
||||||
|
"tool_names": self.tool_names,
|
||||||
|
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"board_id": self.board_id,
|
||||||
|
"message_index": self.message_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content extraction and redaction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _extract_text(content: object) -> tuple[str, bool]:
|
||||||
|
"""Return (preview_text, was_truncated) from a message content value."""
|
||||||
|
if content is None:
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
if isinstance(content, str):
|
||||||
|
text = content
|
||||||
|
truncated = len(text) > CONTENT_PREVIEW_MAX
|
||||||
|
return text[:CONTENT_PREVIEW_MAX], truncated
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
btype = block.get("type", "")
|
||||||
|
if btype == "text":
|
||||||
|
parts.append(str(block.get("text") or ""))
|
||||||
|
elif btype == "tool_use":
|
||||||
|
name = block.get("name", "tool")
|
||||||
|
if name in _SUMMARISE_TOOLS:
|
||||||
|
parts.append(f"[tool: {name}]")
|
||||||
|
else:
|
||||||
|
parts.append(f"[tool: {name}]")
|
||||||
|
elif btype == "tool_result":
|
||||||
|
result_content = block.get("content")
|
||||||
|
if isinstance(result_content, str):
|
||||||
|
parts.append(f"[result: {result_content[:80]}]")
|
||||||
|
else:
|
||||||
|
parts.append("[result]")
|
||||||
|
combined = " ".join(p for p in parts if p)
|
||||||
|
truncated = len(combined) > CONTENT_PREVIEW_MAX
|
||||||
|
return combined[:CONTENT_PREVIEW_MAX], truncated
|
||||||
|
|
||||||
|
return str(content)[:CONTENT_PREVIEW_MAX], False
|
||||||
|
|
||||||
|
|
||||||
|
def redact_tool_args(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a copy of tool args with sensitive keys replaced by ``[REDACTED]``."""
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return {}
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, value in args.items():
|
||||||
|
if key.lower() in _REDACT_TOOL_ARG_NAMES:
|
||||||
|
result[key] = "[REDACTED]"
|
||||||
|
elif isinstance(value, str) and len(value) > 500:
|
||||||
|
result[key] = value[:200] + "…[truncated]"
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_tool_names(content: object) -> list[str]:
|
||||||
|
"""Return names of all tool_use blocks in a message content."""
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
str(block.get("name") or "unknown")
|
||||||
|
for block in content
|
||||||
|
if isinstance(block, dict) and block.get("type") == "tool_use"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(msg: dict[str, Any]) -> datetime | None:
|
||||||
|
for key in ("timestamp", "created_at", "createdAt", "time"):
|
||||||
|
val = msg.get(key)
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
try:
|
||||||
|
normalized = val.strip().replace("Z", "+00:00")
|
||||||
|
from datetime import timezone
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return parsed
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session key → agent/board correlation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_LEAD_SESSION_RE = re.compile(
|
||||||
|
r"^agent:lead-(?P<board_id>[0-9a-fA-F-]{36}):main$"
|
||||||
|
)
|
||||||
|
_AGENT_SESSION_RE = re.compile(
|
||||||
|
r"^agent:(?P<agent_slug>[^:]+):(?:main|board-(?P<board_id>[0-9a-fA-F-]{36}))$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _correlate_session(session_key: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Return (agent_slug_or_none, board_id_or_none) inferred from the session key."""
|
||||||
|
lead_m = _LEAD_SESSION_RE.match(session_key)
|
||||||
|
if lead_m:
|
||||||
|
return None, lead_m.group("board_id")
|
||||||
|
agent_m = _AGENT_SESSION_RE.match(session_key)
|
||||||
|
if agent_m:
|
||||||
|
return agent_m.group("agent_slug"), agent_m.group("board_id")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Message normaliser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def normalize_message(
|
||||||
|
session_key: str,
|
||||||
|
session_label: str | None,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
index: int,
|
||||||
|
) -> RuntimeMessageEvent:
|
||||||
|
"""Convert one raw chat history message into a RuntimeMessageEvent."""
|
||||||
|
role = str(msg.get("role") or "unknown")
|
||||||
|
model = msg.get("model") or None
|
||||||
|
if model:
|
||||||
|
model = str(model)
|
||||||
|
content = msg.get("content")
|
||||||
|
preview, truncated = _extract_text(content)
|
||||||
|
tool_names = _collect_tool_names(content)
|
||||||
|
ts = _parse_timestamp(msg)
|
||||||
|
agent_id, board_id = _correlate_session(session_key)
|
||||||
|
|
||||||
|
# Stable deduplication key
|
||||||
|
event_id = hashlib.sha256(
|
||||||
|
f"{session_key}:{index}:{role}:{preview[:50]}".encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
|
return RuntimeMessageEvent(
|
||||||
|
event_id=event_id,
|
||||||
|
session_key=session_key,
|
||||||
|
session_label=session_label,
|
||||||
|
role=role,
|
||||||
|
model=model,
|
||||||
|
content_preview=preview,
|
||||||
|
content_truncated=truncated,
|
||||||
|
has_tool_use=bool(tool_names),
|
||||||
|
tool_names=tool_names,
|
||||||
|
timestamp=ts,
|
||||||
|
agent_id=agent_id,
|
||||||
|
board_id=board_id,
|
||||||
|
message_index=index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gateway data fetching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _safe_chat_history(
|
||||||
|
session_key: str,
|
||||||
|
config: GatewayConfig,
|
||||||
|
limit: int = HISTORY_FETCH_LIMIT,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch chat history for one session; return [] on any error."""
|
||||||
|
try:
|
||||||
|
raw = await get_chat_history(session_key, config, limit=limit)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
messages = raw.get("messages") or raw.get("history") or []
|
||||||
|
elif isinstance(raw, list):
|
||||||
|
messages = raw
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
return [m for m in messages if isinstance(m, dict)]
|
||||||
|
except (OpenClawGatewayError, TimeoutError, OSError, RuntimeError) as exc:
|
||||||
|
logger.debug(
|
||||||
|
"runtime_activity.history_fetch_failed session_key=%s error=%s",
|
||||||
|
session_key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_active_sessions(config: GatewayConfig) -> list[dict[str, Any]]:
|
||||||
|
"""Return list of active session dicts from the gateway."""
|
||||||
|
try:
|
||||||
|
raw = await openclaw_call("sessions.list", {"limit": 50}, config=config)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return [s for s in (raw.get("sessions") or []) if isinstance(s, dict)]
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return [s for s in raw if isinstance(s, dict)]
|
||||||
|
except (OpenClawGatewayError, TimeoutError, OSError, RuntimeError) as exc:
|
||||||
|
logger.debug("runtime_activity.sessions_list_failed error=%s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main poll function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def fetch_recent_events(
|
||||||
|
config: GatewayConfig,
|
||||||
|
*,
|
||||||
|
since_ids: set[str] | None = None,
|
||||||
|
max_sessions: int = POLL_HISTORY_SESSIONS_MAX,
|
||||||
|
history_limit: int = HISTORY_FETCH_LIMIT,
|
||||||
|
) -> list[RuntimeMessageEvent]:
|
||||||
|
"""Fetch and normalize recent messages across all active sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Gateway credentials/URL.
|
||||||
|
since_ids: Set of event_ids already seen; new events not in this set
|
||||||
|
are returned. Pass ``None`` for the initial load (returns all).
|
||||||
|
max_sessions: Cap on how many sessions to query per call.
|
||||||
|
history_limit: Number of messages to fetch per session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of new RuntimeMessageEvent objects, oldest-first.
|
||||||
|
"""
|
||||||
|
sessions = await _list_active_sessions(config)
|
||||||
|
sessions = sessions[:max_sessions]
|
||||||
|
|
||||||
|
events: list[RuntimeMessageEvent] = []
|
||||||
|
for session in sessions:
|
||||||
|
key = session.get("key") or session.get("id")
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
continue
|
||||||
|
label = session.get("label") or session.get("name") or None
|
||||||
|
|
||||||
|
messages = await _safe_chat_history(key, config, limit=history_limit)
|
||||||
|
for idx, msg in enumerate(messages):
|
||||||
|
event = normalize_message(key, label, msg, idx)
|
||||||
|
if since_ids is None or event.event_id not in since_ids:
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Sort by timestamp (nulls last), then by session_key + index for stability
|
||||||
|
def sort_key(e: RuntimeMessageEvent) -> tuple:
|
||||||
|
return (
|
||||||
|
e.timestamp or datetime.min,
|
||||||
|
e.session_key,
|
||||||
|
e.message_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
events.sort(key=sort_key)
|
||||||
|
return events
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
# ruff: noqa: INP001
|
||||||
|
"""Unit tests for runtime_activity service helpers.
|
||||||
|
|
||||||
|
All tests are pure-Python — no gateway connection required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.openclaw.runtime_activity import (
|
||||||
|
RuntimeMessageEvent,
|
||||||
|
_collect_tool_names,
|
||||||
|
_correlate_session,
|
||||||
|
_extract_text,
|
||||||
|
_parse_timestamp,
|
||||||
|
normalize_message,
|
||||||
|
redact_tool_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _extract_text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractText:
|
||||||
|
|
||||||
|
def test_string_content_short(self):
|
||||||
|
text, truncated = _extract_text("Hello world")
|
||||||
|
assert text == "Hello world"
|
||||||
|
assert not truncated
|
||||||
|
|
||||||
|
def test_string_content_truncated(self):
|
||||||
|
long_text = "x" * 400
|
||||||
|
text, truncated = _extract_text(long_text)
|
||||||
|
assert len(text) == 300
|
||||||
|
assert truncated
|
||||||
|
|
||||||
|
def test_none_content(self):
|
||||||
|
text, truncated = _extract_text(None)
|
||||||
|
assert text == ""
|
||||||
|
assert not truncated
|
||||||
|
|
||||||
|
def test_list_text_block(self):
|
||||||
|
content = [{"type": "text", "text": "The answer is 42."}]
|
||||||
|
text, truncated = _extract_text(content)
|
||||||
|
assert "42" in text
|
||||||
|
assert not truncated
|
||||||
|
|
||||||
|
def test_list_tool_use_block(self):
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "I'll run a command."},
|
||||||
|
{"type": "tool_use", "name": "bash", "input": {"command": "ls -la"}},
|
||||||
|
]
|
||||||
|
text, _ = _extract_text(content)
|
||||||
|
assert "run a command" in text
|
||||||
|
assert "[tool: bash]" in text
|
||||||
|
|
||||||
|
def test_list_tool_result_string(self):
|
||||||
|
content = [
|
||||||
|
{"type": "tool_result", "tool_use_id": "x", "content": "file1.txt\nfile2.txt"},
|
||||||
|
]
|
||||||
|
text, _ = _extract_text(content)
|
||||||
|
assert "[result:" in text
|
||||||
|
|
||||||
|
def test_list_multiple_text_blocks(self):
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "First."},
|
||||||
|
{"type": "text", "text": "Second."},
|
||||||
|
]
|
||||||
|
text, _ = _extract_text(content)
|
||||||
|
assert "First" in text
|
||||||
|
assert "Second" in text
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
text, truncated = _extract_text([])
|
||||||
|
assert text == ""
|
||||||
|
assert not truncated
|
||||||
|
|
||||||
|
def test_non_dict_blocks_skipped(self):
|
||||||
|
content = ["plain string", {"type": "text", "text": "valid"}]
|
||||||
|
text, _ = _extract_text(content)
|
||||||
|
assert "valid" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# redact_tool_args
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRedactToolArgs:
|
||||||
|
|
||||||
|
def test_no_sensitive_keys(self):
|
||||||
|
args = {"command": "ls -la", "cwd": "/home"}
|
||||||
|
result = redact_tool_args(args)
|
||||||
|
assert result["command"] == "ls -la"
|
||||||
|
assert result["cwd"] == "/home"
|
||||||
|
|
||||||
|
def test_password_redacted(self):
|
||||||
|
result = redact_tool_args({"password": "secret123", "user": "admin"})
|
||||||
|
assert result["password"] == "[REDACTED]"
|
||||||
|
assert result["user"] == "admin"
|
||||||
|
|
||||||
|
def test_token_redacted(self):
|
||||||
|
result = redact_tool_args({"token": "sk-abc123", "action": "read"})
|
||||||
|
assert result["token"] == "[REDACTED]"
|
||||||
|
|
||||||
|
def test_api_key_redacted(self):
|
||||||
|
result = redact_tool_args({"api_key": "abc", "model": "gpt-4"})
|
||||||
|
assert result["api_key"] == "[REDACTED]"
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
result = redact_tool_args({"PASSWORD": "x", "Secret": "y"})
|
||||||
|
assert result["PASSWORD"] == "[REDACTED]"
|
||||||
|
assert result["Secret"] == "[REDACTED]"
|
||||||
|
|
||||||
|
def test_long_string_truncated(self):
|
||||||
|
result = redact_tool_args({"content": "A" * 1000})
|
||||||
|
assert len(result["content"]) < 1000
|
||||||
|
assert "truncated" in result["content"]
|
||||||
|
|
||||||
|
def test_empty_dict(self):
|
||||||
|
assert redact_tool_args({}) == {}
|
||||||
|
|
||||||
|
def test_non_dict_returns_empty(self):
|
||||||
|
assert redact_tool_args("bad") == {} # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_credentials_key_redacted(self):
|
||||||
|
result = redact_tool_args({"credentials": "AKIA...", "region": "us-east-1"})
|
||||||
|
assert result["credentials"] == "[REDACTED]"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _collect_tool_names
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCollectToolNames:
|
||||||
|
|
||||||
|
def test_no_tool_use(self):
|
||||||
|
assert _collect_tool_names("plain text") == []
|
||||||
|
|
||||||
|
def test_single_tool_use(self):
|
||||||
|
content = [{"type": "tool_use", "name": "bash", "input": {}}]
|
||||||
|
assert _collect_tool_names(content) == ["bash"]
|
||||||
|
|
||||||
|
def test_multiple_tool_uses(self):
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "hi"},
|
||||||
|
{"type": "tool_use", "name": "read_file", "input": {}},
|
||||||
|
{"type": "tool_use", "name": "bash", "input": {}},
|
||||||
|
]
|
||||||
|
names = _collect_tool_names(content)
|
||||||
|
assert names == ["read_file", "bash"]
|
||||||
|
|
||||||
|
def test_no_name_fallback(self):
|
||||||
|
content = [{"type": "tool_use"}]
|
||||||
|
assert _collect_tool_names(content) == ["unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_timestamp
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseTimestamp:
|
||||||
|
|
||||||
|
def test_iso_zulu(self):
|
||||||
|
msg = {"timestamp": "2026-05-21T10:00:00Z"}
|
||||||
|
ts = _parse_timestamp(msg)
|
||||||
|
assert isinstance(ts, datetime)
|
||||||
|
assert ts.year == 2026
|
||||||
|
assert ts.hour == 10
|
||||||
|
|
||||||
|
def test_iso_with_offset(self):
|
||||||
|
msg = {"timestamp": "2026-05-21T12:00:00+02:00"}
|
||||||
|
ts = _parse_timestamp(msg)
|
||||||
|
assert ts is not None
|
||||||
|
assert ts.hour == 10 # converted to UTC
|
||||||
|
|
||||||
|
def test_created_at_fallback(self):
|
||||||
|
msg = {"created_at": "2026-05-21T09:30:00Z"}
|
||||||
|
ts = _parse_timestamp(msg)
|
||||||
|
assert ts is not None
|
||||||
|
|
||||||
|
def test_no_timestamp_returns_none(self):
|
||||||
|
assert _parse_timestamp({}) is None
|
||||||
|
|
||||||
|
def test_malformed_returns_none(self):
|
||||||
|
assert _parse_timestamp({"timestamp": "not-a-date"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _correlate_session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCorrelateSession:
|
||||||
|
|
||||||
|
def test_lead_session_extracts_board_id(self):
|
||||||
|
board_id = "d8ec2aa9-fa86-4ab2-9f17-6518ccd600df"
|
||||||
|
agent_slug, bid = _correlate_session(f"agent:lead-{board_id}:main")
|
||||||
|
assert agent_slug is None
|
||||||
|
assert bid == board_id
|
||||||
|
|
||||||
|
def test_agent_session_extracts_slug(self):
|
||||||
|
slug, bid = _correlate_session("agent:my-agent:main")
|
||||||
|
assert slug == "my-agent"
|
||||||
|
assert bid is None
|
||||||
|
|
||||||
|
def test_unknown_format_returns_nones(self):
|
||||||
|
slug, bid = _correlate_session("some-random-key")
|
||||||
|
assert slug is None
|
||||||
|
assert bid is None
|
||||||
|
|
||||||
|
def test_empty_returns_nones(self):
|
||||||
|
slug, bid = _correlate_session("")
|
||||||
|
assert slug is None
|
||||||
|
assert bid is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_message
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNormalizeMessage:
|
||||||
|
|
||||||
|
def _make_msg(self, **kwargs) -> dict:
|
||||||
|
return {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello!",
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_basic_assistant_message(self):
|
||||||
|
msg = self._make_msg()
|
||||||
|
event = normalize_message("agent:test:main", "Test", msg, 0)
|
||||||
|
assert isinstance(event, RuntimeMessageEvent)
|
||||||
|
assert event.role == "assistant"
|
||||||
|
assert event.model == "claude-sonnet-4-6"
|
||||||
|
assert event.content_preview == "Hello!"
|
||||||
|
assert not event.content_truncated
|
||||||
|
assert not event.has_tool_use
|
||||||
|
assert event.session_key == "agent:test:main"
|
||||||
|
assert event.session_label == "Test"
|
||||||
|
assert event.message_index == 0
|
||||||
|
|
||||||
|
def test_user_message(self):
|
||||||
|
msg = {"role": "user", "content": "What time is it?"}
|
||||||
|
event = normalize_message("session:1", None, msg, 1)
|
||||||
|
assert event.role == "user"
|
||||||
|
assert event.model is None
|
||||||
|
assert event.session_label is None
|
||||||
|
|
||||||
|
def test_tool_use_detected(self):
|
||||||
|
msg = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Running command."},
|
||||||
|
{"type": "tool_use", "name": "bash", "input": {"command": "ls"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
event = normalize_message("s", None, msg, 0)
|
||||||
|
assert event.has_tool_use
|
||||||
|
assert "bash" in event.tool_names
|
||||||
|
|
||||||
|
def test_long_content_truncated(self):
|
||||||
|
long = "w" * 400
|
||||||
|
msg = self._make_msg(content=long)
|
||||||
|
event = normalize_message("s", None, msg, 0)
|
||||||
|
assert event.content_truncated
|
||||||
|
assert len(event.content_preview) == 300
|
||||||
|
|
||||||
|
def test_event_id_is_stable(self):
|
||||||
|
msg = self._make_msg()
|
||||||
|
e1 = normalize_message("s", None, msg, 0)
|
||||||
|
e2 = normalize_message("s", None, msg, 0)
|
||||||
|
assert e1.event_id == e2.event_id
|
||||||
|
|
||||||
|
def test_event_id_differs_by_index(self):
|
||||||
|
msg = self._make_msg()
|
||||||
|
e1 = normalize_message("s", None, msg, 0)
|
||||||
|
e2 = normalize_message("s", None, msg, 1)
|
||||||
|
assert e1.event_id != e2.event_id
|
||||||
|
|
||||||
|
def test_board_id_from_lead_session(self):
|
||||||
|
board_id = "d8ec2aa9-fa86-4ab2-9f17-6518ccd600df"
|
||||||
|
msg = self._make_msg()
|
||||||
|
event = normalize_message(f"agent:lead-{board_id}:main", None, msg, 0)
|
||||||
|
assert event.board_id == board_id
|
||||||
|
assert event.agent_id is None
|
||||||
|
|
||||||
|
def test_to_dict_serialisable(self):
|
||||||
|
msg = self._make_msg()
|
||||||
|
event = normalize_message("s", "Label", msg, 0)
|
||||||
|
d = event.to_dict()
|
||||||
|
assert d["role"] == "assistant"
|
||||||
|
assert d["session_label"] == "Label"
|
||||||
|
assert isinstance(d["tool_names"], list)
|
||||||
|
# timestamp is None here since no timestamp in msg
|
||||||
|
assert d["timestamp"] is None
|
||||||
|
|
||||||
|
def test_timestamp_parsed(self):
|
||||||
|
msg = self._make_msg(timestamp="2026-05-21T10:00:00Z")
|
||||||
|
event = normalize_message("s", None, msg, 0)
|
||||||
|
assert event.timestamp is not None
|
||||||
|
assert event.timestamp.hour == 10
|
||||||
|
|
@ -42,6 +42,7 @@ import type {
|
||||||
ListGatewaySessionsApiV1GatewaysSessionsGetParams,
|
ListGatewaySessionsApiV1GatewaysSessionsGetParams,
|
||||||
ListGatewaysApiV1GatewaysGetParams,
|
ListGatewaysApiV1GatewaysGetParams,
|
||||||
OkResponse,
|
OkResponse,
|
||||||
|
ProviderUsageResponse,
|
||||||
RuntimeUsageResponse,
|
RuntimeUsageResponse,
|
||||||
SendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams,
|
SendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams,
|
||||||
SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams,
|
SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams,
|
||||||
|
|
@ -3161,3 +3162,289 @@ export function useGetGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet<
|
||||||
|
|
||||||
return { ...query, queryKey: queryOptions.queryKey };
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return provider-native subscription usage data scraped from the CLI (e.g. ``claude /usage``). Returns an empty results list when USAGE_SCRAPER_ENABLED=false (the default). Enable with USAGE_SCRAPER_ENABLED=true and ensure the required prerequisites (tmux, claude binary) are accessible from the backend process.
|
||||||
|
* @summary Gateway provider-native usage (opt-in scraper)
|
||||||
|
*/
|
||||||
|
export type getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse200 =
|
||||||
|
{
|
||||||
|
data: ProviderUsageResponse;
|
||||||
|
status: 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse422 =
|
||||||
|
{
|
||||||
|
data: HTTPValidationError;
|
||||||
|
status: 422;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponseSuccess =
|
||||||
|
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse200 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponseError =
|
||||||
|
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse422 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse =
|
||||||
|
|
||||||
|
| getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponseSuccess
|
||||||
|
| getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponseError;
|
||||||
|
|
||||||
|
export const getGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetUrl =
|
||||||
|
(gatewayId: string) => {
|
||||||
|
return `/api/v1/gateways/${gatewayId}/provider-usage`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet =
|
||||||
|
async (
|
||||||
|
gatewayId: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse> => {
|
||||||
|
return customFetch<getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetResponse>(
|
||||||
|
getGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetUrl(
|
||||||
|
gatewayId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetQueryKey =
|
||||||
|
(gatewayId: string) => {
|
||||||
|
return [`/api/v1/gateways/${gatewayId}/provider-usage`] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetQueryOptions =
|
||||||
|
<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
gatewayId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
queryOptions?.queryKey ??
|
||||||
|
getGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetQueryKey(
|
||||||
|
gatewayId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
> = ({ signal }) =>
|
||||||
|
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet(gatewayId, {
|
||||||
|
signal,
|
||||||
|
...requestOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !!gatewayId,
|
||||||
|
...queryOptions,
|
||||||
|
} as UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetQueryResult =
|
||||||
|
NonNullable<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
export type GetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetQueryError =
|
||||||
|
HTTPValidationError;
|
||||||
|
|
||||||
|
export function useGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
gatewayId: string,
|
||||||
|
options: {
|
||||||
|
query: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
"initialData"
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): DefinedUseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
export function useGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
gatewayId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
"initialData"
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
export function useGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
gatewayId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @summary Gateway provider-native usage (opt-in scraper)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
gatewayId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
} {
|
||||||
|
const queryOptions =
|
||||||
|
getGetGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGetQueryOptions(
|
||||||
|
gatewayId,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||||
|
TData,
|
||||||
|
TError
|
||||||
|
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,8 @@ export * from "./organizationMemberRead";
|
||||||
export * from "./organizationMemberUpdate";
|
export * from "./organizationMemberUpdate";
|
||||||
export * from "./organizationRead";
|
export * from "./organizationRead";
|
||||||
export * from "./organizationUserRead";
|
export * from "./organizationUserRead";
|
||||||
|
export * from "./providerUsageResponse";
|
||||||
|
export * from "./providerUsageScrapeResult";
|
||||||
export * from "./readyzReadyzGet200";
|
export * from "./readyzReadyzGet200";
|
||||||
export * from "./runtimeUsageBurnRate";
|
export * from "./runtimeUsageBurnRate";
|
||||||
export * from "./runtimeUsageCurrent";
|
export * from "./runtimeUsageCurrent";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Generated by orval v8.3.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Mission Control API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ProviderUsageScrapeResult } from "./providerUsageScrapeResult";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response envelope for GET /gateways/{id}/provider-usage.
|
||||||
|
*/
|
||||||
|
export interface ProviderUsageResponse {
|
||||||
|
gateway_id: string;
|
||||||
|
generated_at: string;
|
||||||
|
scraper_enabled: boolean;
|
||||||
|
results: ProviderUsageScrapeResult[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Generated by orval v8.3.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Mission Control API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured result from one provider-native usage scrape (e.g. Claude CLI /usage).
|
||||||
|
|
||||||
|
Returned by GET /gateways/{id}/provider-usage.
|
||||||
|
All fields are optional — partial data is still useful and expected
|
||||||
|
when CLI output format changes or the session is quiet.
|
||||||
|
*/
|
||||||
|
export interface ProviderUsageScrapeResult {
|
||||||
|
provider: string;
|
||||||
|
source_name: string;
|
||||||
|
scraped_at: string;
|
||||||
|
fresh: boolean;
|
||||||
|
freshness_ttl_seconds: number;
|
||||||
|
current_pct?: number | null;
|
||||||
|
remaining_ms?: number | null;
|
||||||
|
remaining_label?: string | null;
|
||||||
|
weekly_messages_used?: number | null;
|
||||||
|
weekly_messages_limit?: number | null;
|
||||||
|
weekly_tokens_used?: number | null;
|
||||||
|
weekly_cost_usd?: number | null;
|
||||||
|
raw_text?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,10 @@ import type {
|
||||||
} from "@/api/generated/model";
|
} from "@/api/generated/model";
|
||||||
import { Markdown } from "@/components/atoms/Markdown";
|
import { Markdown } from "@/components/atoms/Markdown";
|
||||||
import { ActivityFeed } from "@/components/activity/ActivityFeed";
|
import { ActivityFeed } from "@/components/activity/ActivityFeed";
|
||||||
|
import { RuntimeActivityFeed } from "@/components/activity/RuntimeActivityFeed";
|
||||||
|
import {
|
||||||
|
listGatewaysApiV1GatewaysGet,
|
||||||
|
} from "@/api/generated/gateways/gateways";
|
||||||
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
|
|
@ -391,6 +395,23 @@ export default function ActivityPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isPageActive = usePageActive();
|
const isPageActive = usePageActive();
|
||||||
|
const activeTab = searchParams.get("tab") === "runtime" ? "runtime" : "feed";
|
||||||
|
|
||||||
|
// Gateways — loaded once for the Runtime tab
|
||||||
|
const [runtimeGateways, setRuntimeGateways] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
|
listGatewaysApiV1GatewaysGet({ limit: 50, offset: 0 })
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRuntimeGateways(
|
||||||
|
(res.data.items ?? []).map((gw) => ({ id: gw.id, name: gw.name })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* gateways unavailable — runtime tab shows empty state */});
|
||||||
|
}, [isSignedIn]);
|
||||||
|
|
||||||
const selectedEventId = useMemo(() => {
|
const selectedEventId = useMemo(() => {
|
||||||
const value = searchParams.get("eventId");
|
const value = searchParams.get("eventId");
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
@ -1514,19 +1535,45 @@ export default function ActivityPage() {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ActivityIcon className="h-5 w-5 text-muted-foreground" />
|
<ActivityIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||||
Live feed
|
{activeTab === "runtime" ? "Runtime Activity" : "Live feed"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Realtime task, approval, agent, and board-chat activity
|
{activeTab === "runtime"
|
||||||
across all boards.
|
? "Live session messages from gateway runtime."
|
||||||
|
: "Realtime task, approval, agent, and board-chat activity across all boards."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="mt-3 flex gap-1 border-t border-border pt-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "feed", label: "Board Feed" },
|
||||||
|
{ key: "runtime", label: "Runtime" },
|
||||||
|
] as const
|
||||||
|
).map(({ key, label }) => (
|
||||||
|
<Link
|
||||||
|
key={key}
|
||||||
|
href={key === "feed" ? "/activity" : `/activity?tab=${key}`}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
||||||
|
activeTab === key
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 md:p-8">
|
<div className="p-4 md:p-8">
|
||||||
|
{activeTab === "runtime" ? (
|
||||||
|
<RuntimeActivityFeed gateways={runtimeGateways} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{hasUnresolvedDeepLink ? (
|
{hasUnresolvedDeepLink ? (
|
||||||
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
|
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
Requested activity item is not in the current feed window yet.
|
Requested activity item is not in the current feed window yet.
|
||||||
|
|
@ -1544,6 +1591,8 @@ export default function ActivityPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AlertCircle, Bot, ChevronDown, Cpu, User, Wrench } from "lucide-react";
|
||||||
|
import { useAuth } from "@/auth/clerk";
|
||||||
|
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||||
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
|
import { formatRelativeTimestamp } from "@/lib/formatters";
|
||||||
|
|
||||||
|
interface RuntimeMessage {
|
||||||
|
event_id: string;
|
||||||
|
session_key: string;
|
||||||
|
session_label: string | null;
|
||||||
|
role: string;
|
||||||
|
model: string | null;
|
||||||
|
content_preview: string;
|
||||||
|
content_truncated: boolean;
|
||||||
|
has_tool_use: boolean;
|
||||||
|
tool_names: string[];
|
||||||
|
timestamp: string | null;
|
||||||
|
agent_id: string | null;
|
||||||
|
board_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Gateway {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeActivityFeedProps {
|
||||||
|
gateways: Gateway[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role colours + icons
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ROLE_STYLE: Record<string, { label: string; badge: string; icon: React.ElementType }> = {
|
||||||
|
user: { label: "User", badge: "bg-[color:var(--accent-soft)] text-[color:var(--accent)]", icon: User },
|
||||||
|
assistant: { label: "Assistant", badge: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]", icon: Bot },
|
||||||
|
tool: { label: "Tool", badge: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]", icon: Wrench},
|
||||||
|
};
|
||||||
|
const DEFAULT_ROLE = { label: "System", badge: "bg-[color:var(--surface-strong)] text-muted", icon: Cpu };
|
||||||
|
|
||||||
|
function roleMeta(role: string) {
|
||||||
|
return ROLE_STYLE[role.toLowerCase()] ?? DEFAULT_ROLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSE connection helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = { Accept: "text/event-stream" };
|
||||||
|
if (isLocalAuthMode()) {
|
||||||
|
const token = getLocalAuthToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const clerk = (window as unknown as { Clerk?: { session?: { getToken: () => Promise<string> } } }).Clerk;
|
||||||
|
if (clerk?.session) {
|
||||||
|
const token = await clerk.session.getToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clerk not ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function RuntimeActivityFeed({ gateways }: RuntimeActivityFeedProps) {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const [gatewayId, setGatewayId] = useState<string>(() => gateways[0]?.id ?? "");
|
||||||
|
const [messages, setMessages] = useState<RuntimeMessage[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
const seenIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
if (!gatewayId || !isSignedIn) return;
|
||||||
|
cancelledRef.current = false;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const url = `${baseUrl}/api/v1/gateways/${gatewayId}/runtime-activity/stream`;
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(`Gateway returned ${response.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
setError("Stream unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnected(true);
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (!cancelledRef.current) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
buffer = buffer.replace(/\r\n/g, "\n");
|
||||||
|
let boundary = buffer.indexOf("\n\n");
|
||||||
|
while (boundary !== -1) {
|
||||||
|
const raw = buffer.slice(0, boundary);
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
let eventType = "";
|
||||||
|
let data = "";
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||||
|
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (eventType === "runtime_message" && data) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data) as RuntimeMessage;
|
||||||
|
if (!seenIdsRef.current.has(msg.event_id)) {
|
||||||
|
seenIdsRef.current.add(msg.event_id);
|
||||||
|
setMessages((prev) => [msg, ...prev].slice(0, 300));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boundary = buffer.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelledRef.current) {
|
||||||
|
setError(err instanceof Error ? err.message : "Connection lost");
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [gatewayId, isSignedIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessages([]);
|
||||||
|
seenIdsRef.current = new Set();
|
||||||
|
setConnected(false);
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
if (!gateways.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-muted/50 p-12 text-center">
|
||||||
|
<Cpu className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<p className="font-medium text-foreground">No gateways configured</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure a gateway on a board to see its live activity here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Gateway picker + status */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${
|
||||||
|
connected
|
||||||
|
? "bg-[color:var(--success)]"
|
||||||
|
: "bg-[color:var(--border-strong)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{connected ? "Live" : "Connecting…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gateways.length > 1 && (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={gatewayId}
|
||||||
|
onChange={(e) => setGatewayId(e.target.value)}
|
||||||
|
className="appearance-none rounded-lg border border-border bg-card px-3 py-1.5 pr-8 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{gateways.map((gw) => (
|
||||||
|
<option key={gw.id} value={gw.id}>
|
||||||
|
{gw.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feed */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{messages.length === 0 && connected && (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Waiting for session activity…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const meta = roleMeta(msg.role);
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const shortModel = msg.model?.includes("/")
|
||||||
|
? msg.model.split("/")[1]
|
||||||
|
: msg.model;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.event_id}
|
||||||
|
className="rounded-lg border border-border bg-card px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${meta.badge}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
{msg.session_label && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{msg.session_label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{shortModel && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
{shortModel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{msg.has_tool_use && msg.tool_names.length > 0 && (
|
||||||
|
<span className="rounded-full bg-[color:rgba(251,191,36,0.10)] px-2 py-0.5 text-xs text-[color:var(--warning)]">
|
||||||
|
{msg.tool_names.join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{msg.timestamp
|
||||||
|
? formatRelativeTimestamp(msg.timestamp)
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{msg.content_preview && (
|
||||||
|
<p className="mt-2 line-clamp-3 text-sm text-foreground/80">
|
||||||
|
{msg.content_preview}
|
||||||
|
{msg.content_truncated && (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
[truncated]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{msg.board_id && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
board {msg.board_id.slice(0, 8)}…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue