feat(web): emailgen events in Orchestrator page

The SSE pipe at /orchestrator/events/stream was already streaming
'orchestrator.email.{decky_uuid}' events (the subscription is for the
'orchestrator.>' wildcard), but the consumer side dropped them on the
floor.  Three fixes to close the loop:

* useOrchestratorStream.ts now registers an 'email' SSE listener — the
  EventSource silently ignores frames whose event name has no listener,
  so missing this entry meant every email frame was dropped before
  reaching the page's onEvent handler.

* /api/v1/orchestrator/events accepts kind=email and dispatches to
  list_orchestrator_emails, adapting rows to the existing wire shape:
  subject -> action, sender_email -> src_decky_uuid, recipient_email
  -> dst_decky_uuid, plus email-specific extras (thread_id, language,
  mail_decky_uuid, message_id, in_reply_to) ride along as top-level
  keys.

* Orchestrator.tsx gains an 'email' tab in the kind filter and a
  branch in the row renderer / inspector that:
   - shows full sender / recipient (no UUID truncation),
   - chips the language code next to the subject,
   - relabels ACTION as SUBJECT in the inspector and surfaces
     thread / in-reply-to / mail-decky details.

The 'all' tab continues to show traffic+file only (today's behavior);
operators see emails by switching to the email tab.  A union view at
the API layer is the obvious follow-up but not necessary for now.
This commit is contained in:
2026-04-26 22:56:48 -04:00
parent f97ec4c2c1
commit 818aebadfc
6 changed files with 282 additions and 31 deletions

View File

@@ -1,7 +1,26 @@
"""GET /api/v1/orchestrator/events — paginated orchestrator activity.
Two underlying tables back this endpoint:
* ``orchestrator_events`` — SSH traffic + file ops (kind = ``traffic``, ``file``)
* ``orchestrator_emails`` — emailgen-generated EMLs (kind = ``email``)
When the caller filters ``kind=email`` we dispatch to the emails table
and adapt rows into the same wire shape the dashboard already renders.
The mapping is:
* ``action`` ← email subject
* ``src_decky_uuid`` ← sender_email
* ``dst_decky_uuid`` ← recipient_email
* ``protocol`` ← ``"smtp"``
* email-specific fields (``thread_id``, ``language``, ``mail_decky_uuid``,
``message_id``, ``in_reply_to``) ride along as top-level keys for the
inspector / future per-email views; the existing event renderer
ignores anything it doesn't recognise.
Mirrors :mod:`decnet.web.router.campaigns.api_list_campaigns`. The
orchestrator worker is the sole writer; this surface is read-only.
orchestrator + emailgen workers are the sole writers; this surface is
read-only.
"""
from typing import Any, Optional
@@ -13,6 +32,32 @@ from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
def _adapt_email_row(e: dict[str, Any]) -> dict[str, Any]:
"""Reshape an ``orchestrator_emails`` row into the wire shape the
dashboard's event table understands, while carrying the email-only
fields through as extras."""
return {
"uuid": e.get("uuid"),
"ts": e.get("ts"),
"kind": "email",
"protocol": "smtp",
"action": e.get("subject", ""),
"src_decky_uuid": e.get("sender_email"),
"dst_decky_uuid": e.get("recipient_email"),
"success": bool(e.get("success")),
"payload": e.get("payload", "{}"),
# Email-specific extras (renderer keys off ``kind == 'email'``).
"subject": e.get("subject"),
"sender_email": e.get("sender_email"),
"recipient_email": e.get("recipient_email"),
"language": e.get("language"),
"thread_id": e.get("thread_id"),
"mail_decky_uuid": e.get("mail_decky_uuid"),
"message_id": e.get("message_id"),
"in_reply_to": e.get("in_reply_to"),
}
@router.get(
"/orchestrator/events",
tags=["Orchestrator"],
@@ -26,12 +71,17 @@ router = APIRouter()
async def list_orchestrator_events(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0, le=2147483647),
kind: Optional[str] = Query(None, pattern="^(traffic|file)$"),
kind: Optional[str] = Query(None, pattern="^(traffic|file|email)$"),
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Paginated orchestrator-event list, newest first."""
data = await repo.list_orchestrator_events(
limit=limit, offset=offset, kind=kind,
)
total = await repo.count_orchestrator_events(kind=kind)
if kind == "email":
emails = await repo.list_orchestrator_emails(limit=limit, offset=offset)
total = await repo.count_orchestrator_emails()
data = [_adapt_email_row(e) for e in emails]
else:
data = await repo.list_orchestrator_events(
limit=limit, offset=offset, kind=kind,
)
total = await repo.count_orchestrator_events(kind=kind)
return {"total": total, "limit": limit, "offset": offset, "data": data}