220 lines
7.8 KiB
Python
220 lines
7.8 KiB
Python
"""Read-only workbench runtime aggregation."""
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from psycopg import AsyncConnection
|
|
from pydantic import ValidationError
|
|
|
|
from app.auth.project_dependencies import (
|
|
ProjectContext,
|
|
get_project_context,
|
|
get_project_pg_connection,
|
|
)
|
|
from app.domain.schemas.workbench_runtime import (
|
|
WorkbenchAnalysisHistoryPage,
|
|
WorkbenchPersistedAlert,
|
|
WorkbenchRuntimeLatestAnalysis,
|
|
WorkbenchRuntimeSnapshot,
|
|
)
|
|
from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState
|
|
from app.infra.db.postgresql.analysis import AnalysisRepository
|
|
from app.infra.db.postgresql.workbench_operations import (
|
|
WorkbenchOperationsRepository,
|
|
)
|
|
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
|
from app.services.task_alerts import TaskAlertService
|
|
from app.domain.domain_errors import DomainError
|
|
from app.services.workbench_runtime import (
|
|
build_workbench_runtime_snapshot,
|
|
project_analysis_history_item,
|
|
project_latest_analysis,
|
|
project_sensor_location_map_payload,
|
|
sensor_location_ids_for_map_projection,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _projection_transaction(conn: AsyncConnection):
|
|
"""Isolate an optional projection so one SQL failure cannot poison others."""
|
|
transaction = getattr(conn, "transaction", None)
|
|
if transaction is None: # Lightweight endpoint test doubles.
|
|
yield
|
|
return
|
|
async with transaction():
|
|
yield
|
|
|
|
|
|
@router.get(
|
|
"/workbench/analysis-runs",
|
|
response_model=WorkbenchAnalysisHistoryPage,
|
|
)
|
|
async def workbench_analysis_runs(
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
|
):
|
|
"""Return a bounded, sanitized analysis history page for the workbench."""
|
|
rows = await AnalysisRepository.list_run_summaries(
|
|
conn,
|
|
limit=limit + 1,
|
|
offset=offset,
|
|
)
|
|
has_more = len(rows) > limit
|
|
items = rows[:limit]
|
|
return {
|
|
"items": [project_analysis_history_item(run) for run in items],
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"next_offset": offset + limit if has_more else None,
|
|
}
|
|
|
|
|
|
@router.get(
|
|
"/workbench/analysis-runs/{run_id}",
|
|
response_model=WorkbenchRuntimeLatestAnalysis,
|
|
)
|
|
async def workbench_analysis_run(
|
|
run_id: UUID,
|
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
|
):
|
|
"""Return one authorized project's analysis run as a typed workbench artifact."""
|
|
run = await AnalysisRepository.get_run(conn, run_id)
|
|
if run is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Analysis run not found",
|
|
)
|
|
results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100)
|
|
try:
|
|
referenced_map_payload = await _sensor_location_map_payload(conn, results)
|
|
except Exception:
|
|
referenced_map_payload = None
|
|
logger.warning(
|
|
"Workbench analysis spatial references are unavailable",
|
|
exc_info=True,
|
|
)
|
|
return project_latest_analysis(
|
|
run,
|
|
results,
|
|
referenced_map_payload=referenced_map_payload,
|
|
)
|
|
|
|
|
|
@router.post("/workbench/task-alert-events", response_model=TaskAlertState)
|
|
async def push_task_alert_event(
|
|
command: TaskAlertCommand,
|
|
ctx: ProjectContext = Depends(get_project_context),
|
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
|
):
|
|
try:
|
|
event = TaskAlertEvent(
|
|
**command.model_dump(),
|
|
producer_id=f"principal:{ctx.user_id}",
|
|
)
|
|
return await TaskAlertService(
|
|
TaskAlertRepository(conn, ctx.project_id), ctx
|
|
).push(event)
|
|
except DomainError as exc:
|
|
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
|
|
|
|
|
|
@router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot)
|
|
async def workbench_runtime(
|
|
ctx: ProjectContext = Depends(get_project_context),
|
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
|
):
|
|
analysis_runs: list[dict] | None = None
|
|
latest_results: list[dict] | None = None
|
|
latest_analysis_map_payload: dict | None = None
|
|
analysis_availability = "not_configured"
|
|
alert_storage_configured = False
|
|
alerts: list[dict] | None = None
|
|
schedule_availability = "not_configured"
|
|
alert_availability = "not_configured"
|
|
try:
|
|
async with _projection_transaction(conn):
|
|
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
|
|
if analysis_runs:
|
|
latest_results = await AnalysisRepository.list_recent_results(
|
|
conn, analysis_runs[0]["run_id"], limit=100
|
|
)
|
|
analysis_availability = "ready" if analysis_runs else "not_configured"
|
|
except Exception:
|
|
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
|
|
analysis_runs = None
|
|
latest_results = None
|
|
analysis_availability = "degraded"
|
|
if analysis_runs and latest_results:
|
|
try:
|
|
async with _projection_transaction(conn):
|
|
latest_analysis_map_payload = await _sensor_location_map_payload(
|
|
conn, latest_results
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Workbench analysis spatial references are unavailable",
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
async with _projection_transaction(conn):
|
|
operations_availability = await WorkbenchOperationsRepository.availability(conn)
|
|
alert_storage_configured = operations_availability.alerts
|
|
except Exception:
|
|
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
|
|
schedule_availability = "degraded"
|
|
alert_availability = "degraded"
|
|
if alert_storage_configured:
|
|
try:
|
|
async with _projection_transaction(conn):
|
|
alert_documents = await WorkbenchOperationsRepository.list_alerts(
|
|
conn, str(ctx.project_id)
|
|
)
|
|
alerts, invalid_alerts = _validate_documents(
|
|
alert_documents, WorkbenchPersistedAlert, "alert"
|
|
)
|
|
alert_availability = "degraded" if invalid_alerts else "ready"
|
|
except Exception:
|
|
alerts = None
|
|
alert_availability = "degraded"
|
|
logger.warning("Workbench alert projection is unavailable", exc_info=True)
|
|
return await build_workbench_runtime_snapshot(
|
|
analysis_runs=analysis_runs,
|
|
latest_analysis_results=latest_results,
|
|
latest_analysis_map_payload=latest_analysis_map_payload,
|
|
alert_storage_configured=alert_storage_configured,
|
|
persisted_alerts=alerts,
|
|
scenario_availability=analysis_availability,
|
|
artifact_availability=analysis_availability,
|
|
schedule_availability=schedule_availability,
|
|
alert_availability=alert_availability,
|
|
projection_degraded=False,
|
|
)
|
|
|
|
|
|
async def _sensor_location_map_payload(
|
|
conn: AsyncConnection, results: list[dict]
|
|
) -> dict | None:
|
|
node_ids = sensor_location_ids_for_map_projection(results)
|
|
if not node_ids:
|
|
return None
|
|
map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids)
|
|
return project_sensor_location_map_payload(node_ids, map_points)
|
|
|
|
|
|
def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]:
|
|
valid: list[dict] = []
|
|
rejected = 0
|
|
for document in documents:
|
|
try:
|
|
valid.append(model.model_validate(document).model_dump())
|
|
except ValidationError:
|
|
rejected += 1
|
|
logger.warning("Workbench %s document was rejected", label)
|
|
return valid, rejected
|