merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -0,0 +1,123 @@
"""SSE stream of orchestrator events.
Subscribes to ``orchestrator.>`` for the duration of the request and
forwards each event as a Server-Sent Event. Emits a one-shot snapshot
on connect (latest 50 rows).
Mirror of :mod:`decnet.web.router.campaigns.api_events`.
"""
from __future__ import annotations
import asyncio
from typing import AsyncGenerator
import orjson
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from decnet.bus import topics as _topics
from decnet.bus.app import get_app_bus
from decnet.logging import get_logger
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_stream_viewer
from decnet.web.sse_limits import sse_connection_slot
log = get_logger("api.orchestrator.events")
router = APIRouter()
_KEEPALIVE_SECS = 15.0
_SNAPSHOT_LIMIT = 50
def _format_sse(event_name: str, data: dict) -> str:
return f"event: {event_name}\ndata: {orjson.dumps(data).decode()}\n\n"
@router.get(
"/orchestrator/events/stream",
tags=["Orchestrator"],
responses={
200: {
"content": {"text/event-stream": {}},
"description": "SSE stream of orchestrator events",
},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
429: {"description": "Per-user SSE connection cap reached"},
},
)
@_traced("api.orchestrator.events")
async def api_orchestrator_events(
request: Request,
user: dict = Depends(require_stream_viewer),
) -> StreamingResponse:
snapshot = await repo.list_orchestrator_events(
limit=_SNAPSHOT_LIMIT, offset=0,
)
async def generator() -> AsyncGenerator[str, None]:
async with sse_connection_slot(user["uuid"]):
yield ": keepalive\n\n"
yield _format_sse("snapshot", {"events": snapshot})
bus = await get_app_bus()
if bus is None:
while not await request.is_disconnected():
try:
await asyncio.sleep(_KEEPALIVE_SECS)
except asyncio.CancelledError:
break
yield ": keepalive\n\n"
return
sub = bus.subscribe(f"{_topics.ORCHESTRATOR}.>")
try:
async with sub:
sub_iter = sub.__aiter__()
while True:
if await request.is_disconnected():
break
next_task = asyncio.ensure_future(sub_iter.__anext__())
try:
event = await asyncio.wait_for(
next_task, timeout=_KEEPALIVE_SECS,
)
except asyncio.TimeoutError:
next_task.cancel()
yield ": keepalive\n\n"
continue
except StopAsyncIteration:
break
yield _format_sse(
_sse_name_for(event.topic),
{
"topic": event.topic,
"type": event.type,
"ts": event.ts,
"payload": event.payload,
},
)
except asyncio.CancelledError:
pass
except Exception:
log.exception("orchestrator events stream crashed")
yield _format_sse("error", {"message": "Stream interrupted"})
return StreamingResponse(
generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
def _sse_name_for(topic: str) -> str:
"""``orchestrator.traffic.<uuid>`` → ``traffic``;
``orchestrator.file.<uuid>`` → ``file``."""
parts = topic.split(".", 2)
if len(parts) >= 2 and parts[0] == _topics.ORCHESTRATOR:
return parts[1]
return topic

View File

@@ -0,0 +1,87 @@
"""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 + emailgen workers are the sole writers; this surface is
read-only.
"""
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.telemetry import traced as _traced
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"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
422: {"description": "Validation error"},
},
)
@_traced("api.list_orchestrator_events")
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|email)$"),
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Paginated orchestrator-event list, newest first."""
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}