merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
decnet/web/router/orchestrator/__init__.py
Normal file
0
decnet/web/router/orchestrator/__init__.py
Normal file
123
decnet/web/router/orchestrator/api_events.py
Normal file
123
decnet/web/router/orchestrator/api_events.py
Normal 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
|
||||
87
decnet/web/router/orchestrator/api_list_events.py
Normal file
87
decnet/web/router/orchestrator/api_list_events.py
Normal 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}
|
||||
Reference in New Issue
Block a user