feat(maintenance): add dashboard preview and expandable event list

- Add POST /api/maintenance/preview-dboards endpoint for table-to-dashboard preview
- Extend GET /api/maintenance/events with per-event dashboard list (id+title)
- Add 'Show affected dashboards' button to StartMaintenanceForm
- Add expandable row to MaintenanceEventsTable (click count to see names)
- Resolve dashboard titles via SupersetClient.get_dashboards() lookup map
- Add i18n keys for preview feature (en/ru)
This commit is contained in:
2026-06-18 09:02:24 +03:00
parent 28ba0250ba
commit 3c620dfc57
7 changed files with 338 additions and 13 deletions

View File

@@ -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)

View File

@@ -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