feat(web): orchestrator events read API + SSE stream

GET /api/v1/orchestrator/events — paginated list with optional
kind=traffic|file filter. GET /api/v1/orchestrator/events/stream —
SSE: snapshot on connect, live forward of orchestrator.> bus events
mapped to 'traffic' / 'file' SSE event names.

Repo gains list_orchestrator_events(limit, offset, kind?, since_ts?),
count_orchestrator_events(kind?), and prune_orchestrator_events
(per_dst_cap=10000) for periodic worker-side trimming.
This commit is contained in:
2026-04-26 19:58:12 -04:00
parent 900c0c3ef5
commit 5b5ff54fa2
11 changed files with 521 additions and 5 deletions

View File

@@ -55,6 +55,7 @@ from .health import (
)
from .orchestrator import (
OrchestratorEvent,
OrchestratorEventsResponse,
)
from .logs import (
Bounty,
@@ -186,6 +187,7 @@ __all__ = [
"HealthResponse",
# orchestrator
"OrchestratorEvent",
"OrchestratorEventsResponse",
# logs
"Bounty",
"BountyResponse",

View File

@@ -5,9 +5,10 @@ cleanly separable from synthetic life-injection events at query time.
The orchestrator worker is the sole writer.
"""
from datetime import datetime, timezone
from typing import Optional
from typing import Any, List, Optional
from uuid import uuid4
from pydantic import BaseModel
from sqlalchemy import Column, Index, Text
from sqlmodel import Field, SQLModel
@@ -50,3 +51,10 @@ class OrchestratorEvent(SQLModel, table=True):
payload: str = Field(
sa_column=Column("payload", Text, nullable=False, default="{}")
)
class OrchestratorEventsResponse(BaseModel):
total: int
limit: int
offset: int
data: List[dict[str, Any]]

View File

@@ -874,7 +874,31 @@ class BaseRepository(ABC):
raise NotImplementedError
async def list_orchestrator_events(
self, *, limit: int = 100, since_ts: Optional[Any] = None
self,
limit: int = 100,
offset: int = 0,
*,
kind: Optional[str] = None,
since_ts: Optional[Any] = None,
) -> list[dict[str, Any]]:
"""Return recent orchestrator events newest-first."""
"""Paginated orchestrator events newest-first.
``kind`` filters to ``"traffic"`` | ``"file"`` (matches
:class:`OrchestratorEvent.kind`). ``since_ts`` is the snapshot
delta filter used by SSE replay; leave ``None`` for the list view.
"""
raise NotImplementedError
async def count_orchestrator_events(
self, *, kind: Optional[str] = None,
) -> int:
"""Total orchestrator-event rows, optionally filtered by kind."""
raise NotImplementedError
async def prune_orchestrator_events(self, per_dst_cap: int = 10000) -> int:
"""Trim per-``dst_decky_uuid`` rows to a cap. Returns deleted count.
Periodic prune target — keeps the orchestrator_events table from
unbounded growth without paying the cost on every write.
"""
raise NotImplementedError

View File

@@ -2816,14 +2816,59 @@ class SQLModelRepository(BaseRepository):
async def list_orchestrator_events(
self,
*,
limit: int = 100,
offset: int = 0,
*,
kind: Optional[str] = None,
since_ts: Optional[datetime] = None,
) -> list[dict[str, Any]]:
async with self._session() as session:
stmt = select(OrchestratorEvent)
if kind is not None:
stmt = stmt.where(OrchestratorEvent.kind == kind)
if since_ts is not None:
stmt = stmt.where(OrchestratorEvent.ts >= since_ts)
stmt = stmt.order_by(desc(OrchestratorEvent.ts)).limit(limit)
stmt = (
stmt.order_by(desc(OrchestratorEvent.ts))
.offset(offset)
.limit(limit)
)
result = await session.execute(stmt)
return [r.model_dump(mode="json") for r in result.scalars().all()]
async def count_orchestrator_events(
self, *, kind: Optional[str] = None,
) -> int:
stmt = select(func.count()).select_from(OrchestratorEvent)
if kind is not None:
stmt = stmt.where(OrchestratorEvent.kind == kind)
async with self._session() as session:
result = await session.execute(stmt)
return result.scalar() or 0
async def prune_orchestrator_events(self, per_dst_cap: int = 10000) -> int:
"""Trim per-dst rows to *per_dst_cap*, oldest-first. Returns deleted count."""
deleted = 0
async with self._session() as session:
dst_rows = await session.execute(
select(OrchestratorEvent.dst_decky_uuid).distinct()
)
for (dst,) in dst_rows.all():
keep = await session.execute(
select(OrchestratorEvent.uuid)
.where(OrchestratorEvent.dst_decky_uuid == dst)
.order_by(desc(OrchestratorEvent.ts))
.limit(per_dst_cap)
)
keep_uuids = [u for (u,) in keep.all()]
if not keep_uuids:
continue
from sqlalchemy import delete as _delete
stmt = _delete(OrchestratorEvent).where(
OrchestratorEvent.dst_decky_uuid == dst,
OrchestratorEvent.uuid.notin_(keep_uuids),
)
res = await session.execute(stmt)
deleted += res.rowcount or 0
await session.commit()
return deleted