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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user