diff --git a/backend/src/api/routes/maintenance/_routes.py b/backend/src/api/routes/maintenance/_routes.py index 2311e398..452ad820 100644 --- a/backend/src/api/routes/maintenance/_routes.py +++ b/backend/src/api/routes/maintenance/_routes.py @@ -40,6 +40,9 @@ from ._router import router from ._schemas import ( MaintenanceAlreadyActiveResponse, MaintenanceDashboardBannerState, + MaintenanceDashboardInfo, + MaintenanceDashboardPreviewRequest, + MaintenanceDashboardPreviewResponse, MaintenanceEndResponse, MaintenanceEventItem, MaintenanceEventListResponse, @@ -119,7 +122,7 @@ async def list_dashboard_banners( # #region list_events [C:2] [TYPE Function] # @ingroup Api -# @BRIEF Get active and completed maintenance event lists with affected dashboard counts. +# @BRIEF Get active and completed maintenance event lists with affected dashboard counts and names. # @RELATION DEPENDS_ON -> [EXT:code:has_permission_maintenance_READ] @router.get("/events", response_model=MaintenanceEventListResponse) async def list_events( @@ -189,8 +192,39 @@ async def list_events( .all() ) + # ── Build dashboard title lookup from Superset ── + dashboard_title_map: dict[int, str] = {} + try: + from ....dependencies import get_config_manager as _gcm + config_manager = _gcm() + # Collect unique environment IDs from all events + env_ids = {e.environment_id for e in active_events + completed_events if e.environment_id} + for env_id in env_ids: + env = next((e for e in config_manager.get_environments() if e.id == env_id), None) + if env: + try: + from ....core.superset_client import SupersetClient + client = SupersetClient(env) + _, dashboards = await client.get_dashboards() + for d in dashboards: + did = d.get("id") + title = d.get("dashboard_title") or d.get("title") or str(did) + if did is not None: + dashboard_title_map[int(did)] = title + except Exception as e: + app_logger.explore( + "Failed to fetch dashboards for title lookup", + extra={"env_id": env_id}, + error=str(e), + ) + except Exception as e: + app_logger.explore( + "Failed to build dashboard title map", + error=str(e), + ) + def _to_item(event: MaintenanceEvent) -> MaintenanceEventItem: - affected_count = ( + states = ( db.query(MaintenanceDashboardState) .filter( MaintenanceDashboardState.event_id == event.id, @@ -199,8 +233,16 @@ async def list_events( MaintenanceDashboardStateStatus.PENDING_APPLY, ]), ) - .count() + .all() ) + affected_count = len(states) + dashboards = [ + MaintenanceDashboardInfo( + id=state.dashboard_id, + title=dashboard_title_map.get(state.dashboard_id, str(state.dashboard_id)), + ) + for state in states + ] return MaintenanceEventItem( id=event.id, tables=list(event.tables) if event.tables else [], @@ -209,6 +251,7 @@ async def list_events( message=event.message or None, status=event.status.value, affected_count=affected_count, + dashboards=dashboards, created_at=event.created_at.isoformat() if event.created_at else "", ) @@ -219,6 +262,88 @@ async def list_events( # #endregion list_events +# ── POST /api/maintenance/preview-dashboards ──────────────── +# FR-015: operator, admin (NOT viewer) + +# #region preview_dashboards [C:3] [TYPE Function] +# @ingroup Api +# @BRIEF Preview which dashboards would be affected by a maintenance event for given tables. +# Calls find_affected_dashboards then resolves titles from Superset. +# @RELATION DEPENDS_ON -> [EXT:code:has_permission_maintenance_READ] +# @RELATION DEPENDS_ON -> [find_affected_dashboards] +@router.post("/preview-dashboards", response_model=MaintenanceDashboardPreviewResponse) +async def preview_dashboards( + request: MaintenanceDashboardPreviewRequest, + db: Session = Depends(get_db), + _=Depends(has_permission("maintenance", "READ")), +): + with belief_scope("preview_dashboards"): + # Resolve environment + from ....dependencies import get_config_manager as _gcm + config_manager = _gcm() + env = next( + (e for e in config_manager.get_environments() if e.id == request.environment_id), + None, + ) + if not env: + raise HTTPException(status_code=404, detail=f"Environment {request.environment_id} not found") + + from ....core.superset_client import SupersetClient + from ....services.maintenance._dashboard_scanner import find_affected_dashboards + + client = SupersetClient(env) + + # Load settings for scope/excluded/forced filtering + settings = db.query(MaintenanceSettings).filter( + MaintenanceSettings.id == "default" + ).first() + + # Find affected dashboard IDs + try: + dashboard_ids = await find_affected_dashboards( + request.tables, + client, + settings, + ) + except Exception as e: + app_logger.explore( + "Preview failed", + extra={"tables": request.tables}, + error=str(e), + ) + raise HTTPException(status_code=502, detail=f"Failed to query Superset: {e}") + + # Build title lookup + title_map: dict[int, str] = {} + try: + _, all_dashboards = await client.get_dashboards() + for d in all_dashboards: + did = d.get("id") + title = d.get("dashboard_title") or d.get("title") or str(did) + if did is not None: + title_map[int(did)] = title + except Exception as e: + app_logger.explore( + "Failed to fetch dashboards for title resolution", + error=str(e), + ) + + result = [ + MaintenanceDashboardInfo( + id=did, + title=title_map.get(did, str(did)), + ) + for did in dashboard_ids + ] + + app_logger.reflect( + "Preview completed", + extra={"tables": request.tables, "count": len(result)}, + ) + return MaintenanceDashboardPreviewResponse(dashboards=result) +# #endregion preview_dashboards + + # ── POST /api/maintenance/start ────────────────────────────── # FR-001: Always returns 202 {task_id} after validation # FR-015: operator, admin (NOT viewer) diff --git a/backend/src/api/routes/maintenance/_schemas.py b/backend/src/api/routes/maintenance/_schemas.py index c900ee15..178e36c4 100644 --- a/backend/src/api/routes/maintenance/_schemas.py +++ b/backend/src/api/routes/maintenance/_schemas.py @@ -22,6 +22,14 @@ class MaintenanceStartRequest(BaseModel): # #endregion MaintenanceStartRequest +# #region MaintenanceDashboardPreviewRequest [C:1] [TYPE Class] +# @BRIEF POST /api/maintenance/preview-dashboards request body. +class MaintenanceDashboardPreviewRequest(BaseModel): + tables: list[str] = Field(..., min_length=1, max_length=100) + environment_id: str = Field(..., description="Target environment for the preview") +# #endregion MaintenanceDashboardPreviewRequest + + # #region MaintenanceSettingsUpdate [C:1] [TYPE Class] # @BRIEF PUT /api/maintenance/settings request body — all fields optional for partial update. class MaintenanceSettingsUpdate(BaseModel): @@ -114,6 +122,14 @@ class MaintenanceSettingsResponse(BaseModel): # #endregion MaintenanceSettingsResponse +# #region MaintenanceDashboardInfo [C:1] [TYPE Class] +# @BRIEF Dashboard ID + title pair for event detail and preview responses. +class MaintenanceDashboardInfo(BaseModel): + id: int + title: str +# #endregion MaintenanceDashboardInfo + + # #region MaintenanceEventItem [C:1] [TYPE Class] # @BRIEF Single maintenance event summary for GET /api/maintenance/events. class MaintenanceEventItem(BaseModel): @@ -124,6 +140,7 @@ class MaintenanceEventItem(BaseModel): message: str | None = None status: str affected_count: int + dashboards: list[MaintenanceDashboardInfo] = [] created_at: str # #endregion MaintenanceEventItem @@ -154,4 +171,11 @@ class MaintenanceAlreadyActiveResponse(BaseModel): status: str = "already_active" # #endregion MaintenanceAlreadyActiveResponse + +# #region MaintenanceDashboardPreviewResponse [C:1] [TYPE Class] +# @BRIEF Response for POST /api/maintenance/preview-dashboards. +class MaintenanceDashboardPreviewResponse(BaseModel): + dashboards: list[MaintenanceDashboardInfo] +# #endregion MaintenanceDashboardPreviewResponse + # #endregion MaintenanceSchemasModule diff --git a/frontend/src/lib/api/maintenance.ts b/frontend/src/lib/api/maintenance.ts index 1ae0acb1..2f0c4913 100644 --- a/frontend/src/lib/api/maintenance.ts +++ b/frontend/src/lib/api/maintenance.ts @@ -80,4 +80,14 @@ export async function listDashboardBanners(): Promise { return requestApi(`${BASE}/dashboard-banners`, 'GET'); } // #endregion listDashboardBanners + +// #region previewDashboards [C:2] [TYPE Function] [SEMANTICS maintenance, preview, dashboards] +// @BRIEF Preview which dashboards would be affected by maintenance for given tables. +// @PRE params contains { tables: string[], environment_id: string }. +// @POST Returns { dashboards: [{ id, title }] }. +// @RELATION DEPENDS_ON -> [requestApi] +export async function previewDashboards(params: Record): Promise { + return requestApi(`${BASE}/preview-dashboards`, 'POST', params); +} +// #endregion previewDashboards // #endregion MaintenanceApi diff --git a/frontend/src/lib/components/MaintenanceEventsTable.svelte b/frontend/src/lib/components/MaintenanceEventsTable.svelte index 5e42e93a..25214236 100644 --- a/frontend/src/lib/components/MaintenanceEventsTable.svelte +++ b/frontend/src/lib/components/MaintenanceEventsTable.svelte @@ -1,6 +1,6 @@ - + - + @@ -10,9 +10,10 @@ + - +