tasks
This commit is contained in:
66
specs/031-maintenance-banner/checklists/requirements.md
Normal file
66
specs/031-maintenance-banner/checklists/requirements.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Requirements Checklist: Maintenance Banner for Dashboards
|
||||
|
||||
**Purpose**: Validate specification completeness against the feature requirements template and project standards.
|
||||
**Created**: 2026-05-21
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Functional Scope
|
||||
|
||||
- [x] CHK001 All user-facing actions have defined acceptance scenarios (US1–US5 have Given/When/Then)
|
||||
- [x] CHK002 Feature scope is bounded — clear what IS and IS NOT included (no dashboard content modification beyond adding/removing markdown chart)
|
||||
- [x] CHK003 Both API and UI interaction paths are defined
|
||||
- [x] CHK004 External system (ETL tool) integration contract is specified (API request/response shapes)
|
||||
- [x] CHK005 Both dataset types (table-based and virtual/SQL) are addressed in requirements
|
||||
|
||||
## Data Model & Persistence
|
||||
|
||||
- [x] CHK006 All new entities are listed in Key Entities (MaintenanceEvent, MaintenanceDashboardState, MaintenanceSettings)
|
||||
- [x] CHK007 Entity relationships are clear (MaintenanceEvent → MaintenanceDashboardState via maintenance_event_id; Dashboard → Dataset → Table via Superset)
|
||||
- [x] CHK008 Status lifecycle is defined (active → completed/failed, active → removed/removal_failed)
|
||||
- [x] CHK009 Data retention / cleanup requirements are implicit (history is kept, no auto-deletion)
|
||||
|
||||
## Interaction & Usability
|
||||
|
||||
- [x] CHK010 Happy path is documented with narrative and API examples
|
||||
- [x] CHK011 Error scenarios have recovery paths (5 scenarios: table not found, missing dashboard, timeout, conflict, permissions)
|
||||
- [x] CHK012 UX states are enumerated (`@UX_STATE`: idle, loading, action_in_progress, error, empty)
|
||||
- [x] CHK013 UX feedback mechanisms are defined (toast notifications, badges, tooltips)
|
||||
- [x] CHK014 Tone & voice consistent with project terminology
|
||||
|
||||
## Non-Functional Qualities
|
||||
|
||||
- [x] CHK015 Performance targets are set (SC-001: <30s find, SC-002: <60s banner, SC-003: <30s remove)
|
||||
- [x] CHK016 Error handling strategy is defined (partial success with 207, idempotent operations)
|
||||
- [x] CHK017 RBAC enforcement is specified (FR-014: operator/admin roles)
|
||||
- [x] CHK018 Logging and audit trail requirements are implicit (maintenance event history in local DB)
|
||||
|
||||
## Integration & Dependencies
|
||||
|
||||
- [x] CHK019 Superset API dependency is acknowledged (markdown chart creation, dashboard import/export)
|
||||
- [x] CHK020 Existing codebase patterns are respected (SupersetClient, RBAC patterns from ADR-0005)
|
||||
- [x] CHK021 Frontend architecture constraints are noted (Svelte 5 runes per ADR-0006)
|
||||
|
||||
## Edge Cases & Constraints
|
||||
|
||||
- [x] CHK022 Concurrent maintenance events on one dashboard handled
|
||||
- [x] CHK023 Empty dashboards (no datasets) handled
|
||||
- [x] CHK024 Table in multiple datasets within one dashboard handled
|
||||
- [x] CHK025 Network failures with Superset handled
|
||||
- [x] CHK026 Large table lists (100+) considered
|
||||
- [x] CHK027 Dashboard deletion race condition considered
|
||||
- [x] CHK028 Complex SQL parsing for virtual datasets considered
|
||||
- [x] CHK029 Excluded vs forced dashboard priority resolved
|
||||
- [x] CHK030 Timezone handling specified
|
||||
|
||||
## Completion Signals
|
||||
|
||||
- [x] CHK031 All FR items are testable (each FR has at least one acceptance scenario or success criterion)
|
||||
- [x] CHK032 Success criteria are measurable (SC-001 to SC-007 all have quantifiable metrics)
|
||||
- [x] CHK033 No `[NEEDS CLARIFICATION]` markers remain in spec
|
||||
- [x] CHK034 Feature branch matches convention (031-maintenance-banner)
|
||||
|
||||
## Notes
|
||||
|
||||
- SQL parsing accuracy at ≥95% (SC-004) will require a dedicated test corpus of real Superset virtual dataset queries.
|
||||
- The markdown chart placement at "position (0,0)" in Superset depends on Superset's dashboard layout API capabilities — the plan phase should research the exact mechanism (export-import cycle vs direct chart creation + position patching).
|
||||
- Timezone formatting in the banner text should be configurable (UTC by default, but "MSK" shown in examples). This is an implementation detail for the plan phase.
|
||||
235
specs/031-maintenance-banner/contracts/modules.md
Normal file
235
specs/031-maintenance-banner/contracts/modules.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Semantic Contracts: Maintenance Banner for Dashboards
|
||||
|
||||
**Feature Branch**: `031-maintenance-banner`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Phase 1 — Design Contracts
|
||||
|
||||
## Backend Contracts
|
||||
|
||||
### [DEF:MaintenanceEvent:Model]
|
||||
@COMPLEXITY 1
|
||||
@PURPOSE SQLAlchemy ORM model for maintenance event records.
|
||||
@LAYER models
|
||||
@FILE backend/src/models/maintenance.py
|
||||
@RELATION DEPENDS_ON -> [Base:Model]
|
||||
|
||||
Columns: id (str PK), task_id (str, FK→tasks.id), tables (JSON), start_time (datetime tz), end_time (datetime tz), message (text?), status (enum: active/completed/failed), created_at, updated_at.
|
||||
|
||||
### [DEF:MaintenanceDashboardState:Model]
|
||||
@COMPLEXITY 1
|
||||
@PURPOSE Junction model linking maintenance events to dashboards with chart tracking.
|
||||
@LAYER models
|
||||
@FILE backend/src/models/maintenance.py
|
||||
@RELATION DEPENDS_ON -> [Base:Model]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceEvent:Model]
|
||||
|
||||
Columns: id (str PK), event_id (str FK→maintenance_events.id), dashboard_id (int), environment_id (str), chart_id (int?), status (enum: active/removed/removal_failed), created_at, removed_at. Index: (dashboard_id, status).
|
||||
|
||||
### [DEF:MaintenanceSettings:Model]
|
||||
@COMPLEXITY 1
|
||||
@PURPOSE Single-row configuration table for maintenance mode settings.
|
||||
@LAYER models
|
||||
@FILE backend/src/models/maintenance.py
|
||||
@RELATION DEPENDS_ON -> [Base:Model]
|
||||
|
||||
Columns: id (str PK, "default"), target_environment_id (str), dashboard_scope (enum: published_only/draft_only/all), excluded_dashboard_ids (JSON), forced_dashboard_ids (JSON), updated_at.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceSchemas:Module]
|
||||
@COMPLEXITY 1
|
||||
@PURPOSE Pydantic request/response schemas for maintenance API endpoints.
|
||||
@LAYER schemas
|
||||
@FILE backend/src/api/routes/maintenance/_schemas.py
|
||||
@RELATION DEPENDS_ON -> [pydantic.BaseModel]
|
||||
|
||||
Schemas: MaintenanceStartRequest, MaintenanceSettingsUpdate, MaintenanceStartResponse, MaintenanceTaskResult, MaintenanceEndResponse, MaintenanceEndTaskResult, MaintenanceEndAllTaskResult, MaintenanceSettingsResponse, MaintenanceEventItem, MaintenanceEventListResponse, MaintenanceDashboardBannerState.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:SupersetDashboardsWriteMixin:Class]
|
||||
@COMPLEXITY 4
|
||||
@PURPOSE Extends SupersetClient with write operations: create markdown chart, update dashboard layout, delete chart.
|
||||
@LAYER core
|
||||
@FILE backend/src/core/superset_client/_dashboards_write.py
|
||||
@RELATION DEPENDS_ON -> [SupersetClientBase:Class]
|
||||
@RELATION DEPENDS_ON -> [NetworkClient:Class]
|
||||
@PRE SupersetClient is initialized with valid bearer token and environment base URL.
|
||||
@POST Chart created in Superset; chart_id returned. Dashboard position_json updated.
|
||||
@SIDE_EFFECT Modifies Superset dashboards via REST API (POST /chart/, PUT /dashboard/{id}, DELETE /chart/{id}).
|
||||
@RATIONALE Direct chart API is faster than export-import cycle for single-chart operations (see research.md R1).
|
||||
|
||||
Methods:
|
||||
- `create_markdown_chart(dashboard_id: int, markdown_text: str) -> int` — Creates viz_type="markdown" chart, returns chart_id.
|
||||
- `update_dashboard_layout(dashboard_id: int, chart_id: int, position: str) -> dict` — Patches position_json to insert chart at given position.
|
||||
- `delete_chart(chart_id: int) -> bool` — Deletes a chart from Superset.
|
||||
- `get_dashboard_layout(dashboard_id: int) -> dict` — Fetches current position_json for reading existing layout.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:SqlTableExtractor:Module]
|
||||
@COMPLEXITY 2
|
||||
@PURPOSE Extract fully-qualified table names (schema.table) from Superset virtual dataset SQL+Jinja text. Uses two-phase approach: (1) regex global extraction of all schema.table candidates from raw text, (2) sqlparse filtering to reject candidates inside SQL string literals. Jinja is NOT pre-stripped — table names inside {% set %} blocks are preserved.
|
||||
@LAYER services
|
||||
@FILE backend/src/services/sql_table_extractor.py
|
||||
@RELATION DEPENDS_ON -> [sqlparse]
|
||||
@RATIONALE Table names inside Jinja {% set %} blocks (common in Superset virtual datasets) are invisible to SQL parsers after Jinja stripping. Global extraction first, then false-positive filtering, ensures full coverage. See research.md R3.
|
||||
|
||||
Functions:
|
||||
- `extract_tables_from_sql(raw_sql: str) -> set[str]` — Orchestrator: calls extract_candidates then filter_string_literals. Returns deduplicated set of "schema.table" names (case-insensitive matching per clarification Q3).
|
||||
- `extract_table_candidates(raw_sql: str) -> list[str]` — Regex finds all `[a-zA-Z][\w]*\.[a-zA-Z][\w]*` patterns across raw text (SQL + Jinja).
|
||||
- `filter_string_literals(raw_sql: str, candidates: list[str]) -> set[str]` — Uses sqlparse tokenizer to reject candidates whose byte position falls inside a SQL string literal or comment.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceService:Class]
|
||||
@COMPLEXITY 4
|
||||
@PURPOSE Core business logic: find affected dashboards, apply banners, remove banners, rebuild aggregated banner text.
|
||||
@LAYER services
|
||||
@FILE backend/src/services/maintenance_service.py
|
||||
@RELATION DEPENDS_ON -> [SupersetClient:Class]
|
||||
@RELATION DEPENDS_ON -> [SupersetDashboardsWriteMixin:Class]
|
||||
@RELATION DEPENDS_ON -> [SqlTableExtractor:Module]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceEvent:Model]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceDashboardState:Model]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceSettings:Model]
|
||||
@RELATION DEPENDS_ON -> [TaskManager:Class]
|
||||
@PRE Active database session available. Superset client initialized for target environment.
|
||||
@POST MaintenanceEvent created/updated. MaintenanceDashboardState records created/updated. Chart added/removed from Superset dashboards.
|
||||
@SIDE_EFFECT Creates/deletes charts in Superset; writes to maintenance_events and maintenance_dashboard_states tables; dispatches WebSocket task progress events.
|
||||
@RATIONALE Single service orchestrates the full maintenance lifecycle, ensuring consistency between local state and Superset state (see research.md R2).
|
||||
|
||||
Methods:
|
||||
- `start_maintenance(tables, start_time, end_time, message, db_session) -> MaintenanceEvent` — C4: Discovery + banner placement.
|
||||
- `end_maintenance(event_id, db_session) -> None` — C4: Banner removal for one event.
|
||||
- `end_all_maintenance(db_session) -> None` — C4: Bulk banner removal.
|
||||
- `find_affected_dashboards(tables, superset_client) -> list[dict]` — C3: Cross-references tables against datasets.
|
||||
- `build_banner_text(events: list[MaintenanceEvent]) -> str` — C2: Aggregates text from multiple active events.
|
||||
- `rebuild_banner(dashboard_id, superset_client, db_session) -> None` — C3: Updates chart markdown text in Superset.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceRoutes:Module]
|
||||
@COMPLEXITY 3
|
||||
@PURPOSE FastAPI route handlers for maintenance API endpoints.
|
||||
@LAYER api
|
||||
@FILE backend/src/api/routes/maintenance/_routes.py
|
||||
@RELATION DEPENDS_ON -> [MaintenanceService:Class]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceSchemas:Module]
|
||||
@RELATION DEPENDS_ON -> [TaskManager:Class]
|
||||
@RELATION DEPENDS_ON -> [AuthMiddleware:Class]
|
||||
|
||||
Endpoints:
|
||||
- `POST /api/maintenance/start` → MaintenanceStartResponse
|
||||
- `POST /api/maintenance/{maintenance_id}/end` → MaintenanceEndResponse
|
||||
- `POST /api/maintenance/end-all` → MaintenanceEndResponse
|
||||
- `GET /api/maintenance/events` → MaintenanceEventListResponse
|
||||
- `GET /api/maintenance/settings` → MaintenanceSettingsResponse
|
||||
- `PUT /api/maintenance/settings` → MaintenanceSettingsResponse
|
||||
- `GET /api/maintenance/dashboard-banners` → list[MaintenanceDashboardBannerState]
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceRouter:Module]
|
||||
@COMPLEXITY 2
|
||||
@PURPOSE APIRouter assembly with prefix and tag registration.
|
||||
@LAYER api
|
||||
@FILE backend/src/api/routes/maintenance/_router.py
|
||||
@RELATION DEPENDS_ON -> [MaintenanceRoutes:Module]
|
||||
|
||||
Creates `APIRouter(prefix="/api/maintenance", tags=["maintenance"])` and includes all route handlers.
|
||||
|
||||
## Frontend Contracts
|
||||
|
||||
### [DEF:MaintenanceStore:Module]
|
||||
@COMPLEXITY 3
|
||||
@PURPOSE Svelte 5 rune store for maintenance event state, shared across components.
|
||||
@LAYER store
|
||||
@FILE frontend/src/lib/stores/maintenance.svelte.js
|
||||
@RELATION DEPENDS_ON -> [requestApi:Function]
|
||||
@RATIONALE Shared store enables maintenance badge in Dashboard Hub and management page to share state without prop drilling (ADR-0006).
|
||||
|
||||
State:
|
||||
- `events`: $state — active and completed event lists.
|
||||
- `settings`: $state — MaintenanceSettings object.
|
||||
- `dashboardBanners`: $state — Map<dashboardId, bannerState> for Dashboard Hub indicator.
|
||||
- `isLoading`: $state — loading flag.
|
||||
- `error`: $state — error message.
|
||||
|
||||
### [DEF:MaintenanceApiClient:Module]
|
||||
@COMPLEXITY 2
|
||||
@PURPOSE Typed fetch wrappers for all maintenance API endpoints.
|
||||
@LAYER api
|
||||
@FILE frontend/src/lib/api/maintenance.js
|
||||
@RELATION DEPENDS_ON -> [requestApi:Function]
|
||||
|
||||
Functions:
|
||||
- `startMaintenance(payload)` → `{task_id, maintenance_id}`
|
||||
- `endMaintenance(maintenanceId)` → `{task_id}`
|
||||
- `endAllMaintenance()` → `{task_id}`
|
||||
- `getEvents()` → `{active, completed}`
|
||||
- `getSettings()` → `MaintenanceSettingsResponse`
|
||||
- `updateSettings(payload)` → `MaintenanceSettingsResponse`
|
||||
- `getDashboardBanners()` → `Map<id, state>`
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceBannerPage:Component]
|
||||
@COMPLEXITY 3
|
||||
@PURPOSE Management page: settings panel + active/completed events tables + bulk actions.
|
||||
@LAYER route
|
||||
@FILE frontend/src/routes/maintenance/+page.svelte
|
||||
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceSettingsPanel:Component]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceEventsTable:Component]
|
||||
@UX_STATE idle -> Page loaded with settings and events tables.
|
||||
@UX_STATE loading -> Skeleton placeholders for both panels.
|
||||
@UX_STATE action_in_progress -> Remove button shows spinner; other actions disabled.
|
||||
@UX_STATE error -> Error toast with retry button.
|
||||
@UX_STATE empty -> "No active maintenance events" placeholder.
|
||||
@UX_FEEDBACK Toast on success (green), error (red), warning (yellow partial).
|
||||
@UX_RECOVERY Retry button on error. Auto-polling resumes after recovery.
|
||||
@UX_REACTIVITY $effect polls MaintenanceStore every 30s.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceSettingsPanel:Component]
|
||||
@COMPLEXITY 3
|
||||
@PURPOSE Collapsible settings form: dashboard scope radio, excluded/forced dashboard multi-select.
|
||||
@LAYER component
|
||||
@FILE frontend/src/lib/components/MaintenanceSettingsPanel.svelte
|
||||
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
|
||||
@UX_STATE idle -> Form with current values.
|
||||
@UX_STATE saving -> Save button disabled, spinner.
|
||||
@UX_STATE saved -> Brief green flash on form border.
|
||||
@UX_STATE error -> Red border, error message below form.
|
||||
@UX_FEEDBACK Toast on save success/failure.
|
||||
@UX_RECOVERY Retry save on error.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:MaintenanceEventsTable:Component]
|
||||
@COMPLEXITY 3
|
||||
@PURPOSE Table of active/completed maintenance events with per-event and bulk removal actions.
|
||||
@LAYER component
|
||||
@FILE frontend/src/lib/components/MaintenanceEventsTable.svelte
|
||||
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
|
||||
@RELATION DEPENDS_ON -> [MaintenanceApiClient:Module]
|
||||
@UX_STATE idle -> Rows with action buttons.
|
||||
@UX_STATE removing -> Row action button shows spinner, Confirm dialog for "Remove All".
|
||||
@UX_STATE error -> Failed rows highlighted red with retry button.
|
||||
@UX_FEEDBACK Toast on removal success/failure. Confirmation dialog for "Remove All".
|
||||
@UX_RECOVERY Per-row retry on removal failure.
|
||||
|
||||
---
|
||||
|
||||
### [DEF:DashboardGridBannerIndicator:Component]
|
||||
@COMPLEXITY 2
|
||||
@PURPOSE Maintenance badge cell in Dashboard Hub table — shows orange badge when dashboard has active banner.
|
||||
@LAYER component
|
||||
@FILE frontend/src/lib/components/DashboardMaintenanceBadge.svelte
|
||||
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
|
||||
@UX_STATE no_maintenance -> No badge rendered (empty cell).
|
||||
@UX_STATE maintenance_active -> Orange badge "⚠️ Maintenance" with tooltip.
|
||||
@UX_STATE loading -> Grey skeleton badge.
|
||||
@UX_REACTIVITY $derived from MaintenanceStore.dashboardBanners.
|
||||
259
specs/031-maintenance-banner/data-model.md
Normal file
259
specs/031-maintenance-banner/data-model.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Data Model: Maintenance Banner for Dashboards
|
||||
|
||||
**Feature Branch**: `031-maintenance-banner`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Phase 1 — Design
|
||||
|
||||
## Entity-Relationship Overview
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌─────────────────────────────┐
|
||||
│ MaintenanceSettings │ │ MaintenanceEvent │
|
||||
│ (single row) │ │ │
|
||||
│ ────────────────── │ │ id: str (PK) │
|
||||
│ target_environment │◄──────│ task_id: str (FK→Task) │
|
||||
│ dashboard_scope │ │ tables: JSON (str[]) │
|
||||
│ excluded_ids: JSON │ │ start_time: datetime │
|
||||
│ forced_ids: JSON │ │ end_time: datetime │
|
||||
│ updated_at │ │ message: str? │
|
||||
└──────────────────────┘ │ status: enum(active|comp|fail)│
|
||||
│ created_at / updated_at │
|
||||
└──────────┬────────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ MaintenanceDashboardState │
|
||||
│ │
|
||||
│ id: str (PK) │
|
||||
│ event_id: str (FK→Event) │
|
||||
│ dashboard_id: int │
|
||||
│ environment_id: str │
|
||||
│ chart_id: int? (Superset) │
|
||||
│ status: enum(active|removed │
|
||||
│ |removal_failed) │
|
||||
│ created_at / removed_at │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## SQLAlchemy Models
|
||||
|
||||
### MaintenanceEvent
|
||||
|
||||
```python
|
||||
# backend/src/models/maintenance.py
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, JSON, Enum, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from src.models.base import Base
|
||||
import enum
|
||||
|
||||
class MaintenanceEventStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
class MaintenanceEvent(Base):
|
||||
__tablename__ = "maintenance_events"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # "m-ev-20260521-abc123"
|
||||
task_id = Column(String, nullable=True, index=True) # FK to tasks.id (loose coupling)
|
||||
tables = Column(JSON, nullable=False) # ["raw.sales", "raw.inventory"]
|
||||
start_time = Column(DateTime(timezone=True), nullable=False)
|
||||
end_time = Column(DateTime(timezone=True), nullable=False)
|
||||
message = Column(Text, nullable=True)
|
||||
status = Column(
|
||||
Enum(MaintenanceEventStatus),
|
||||
nullable=False,
|
||||
default=MaintenanceEventStatus.ACTIVE,
|
||||
index=True
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default="now()")
|
||||
updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()")
|
||||
|
||||
# Relationships
|
||||
dashboard_states = relationship(
|
||||
"MaintenanceDashboardState",
|
||||
back_populates="maintenance_event",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
```
|
||||
|
||||
### MaintenanceDashboardState
|
||||
|
||||
```python
|
||||
class MaintenanceDashboardStateStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
REMOVED = "removed"
|
||||
REMOVAL_FAILED = "removal_failed"
|
||||
|
||||
class MaintenanceDashboardState(Base):
|
||||
__tablename__ = "maintenance_dashboard_states"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
event_id = Column(String, ForeignKey("maintenance_events.id"), nullable=False, index=True)
|
||||
dashboard_id = Column(Integer, nullable=False, index=True) # Superset dashboard ID
|
||||
environment_id = Column(String, nullable=False) # Target environment ID
|
||||
chart_id = Column(Integer, nullable=True) # Superset markdown chart ID (null until created)
|
||||
status = Column(
|
||||
Enum(MaintenanceDashboardStateStatus),
|
||||
nullable=False,
|
||||
default=MaintenanceDashboardStateStatus.ACTIVE
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default="now()")
|
||||
removed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
maintenance_event = relationship("MaintenanceEvent", back_populates="dashboard_states")
|
||||
|
||||
# Composite index for dashboard hub queries
|
||||
__table_args__ = (
|
||||
Index("ix_mds_dashboard_status", "dashboard_id", "status"),
|
||||
)
|
||||
```
|
||||
|
||||
### MaintenanceSettings
|
||||
|
||||
```python
|
||||
class DashboardScope(str, enum.Enum):
|
||||
PUBLISHED_ONLY = "published_only"
|
||||
DRAFT_ONLY = "draft_only"
|
||||
ALL = "all"
|
||||
|
||||
class MaintenanceSettings(Base):
|
||||
__tablename__ = "maintenance_settings"
|
||||
|
||||
id = Column(String, primary_key=True, default="default") # Single-row: "default"
|
||||
target_environment_id = Column(String, nullable=False) # Production env ID
|
||||
dashboard_scope = Column(
|
||||
Enum(DashboardScope),
|
||||
nullable=False,
|
||||
default=DashboardScope.PUBLISHED_ONLY
|
||||
)
|
||||
excluded_dashboard_ids = Column(JSON, nullable=False, default=list)
|
||||
forced_dashboard_ids = Column(JSON, nullable=False, default=list)
|
||||
updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()")
|
||||
```
|
||||
|
||||
## Pydantic Schemas
|
||||
|
||||
### Request Schemas
|
||||
|
||||
```python
|
||||
# backend/src/api/routes/maintenance/_schemas.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
class MaintenanceStartRequest(BaseModel):
|
||||
tables: list[str] = Field(..., min_length=1, description="Fully-qualified table names (schema.table)")
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
message: str | None = Field(None, max_length=500)
|
||||
|
||||
class MaintenanceSettingsUpdate(BaseModel):
|
||||
target_environment_id: str | None = None
|
||||
dashboard_scope: str | None = None # "published_only" | "draft_only" | "all"
|
||||
excluded_dashboard_ids: list[int] | None = None
|
||||
forced_dashboard_ids: list[int] | None = None
|
||||
```
|
||||
|
||||
### Response Schemas
|
||||
|
||||
```python
|
||||
class MaintenanceStartResponse(BaseModel):
|
||||
task_id: str
|
||||
maintenance_id: str
|
||||
status: str # "pending"
|
||||
|
||||
class MaintenanceDashboardResult(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
chart_id: int | None = None
|
||||
|
||||
class MaintenanceFailedDashboard(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
error: str
|
||||
|
||||
class MaintenanceTaskResult(BaseModel):
|
||||
maintenance_id: str
|
||||
status: str # "active" | "partial" | "completed" | "no_match"
|
||||
affected_dashboards: int = 0
|
||||
dashboards: list[MaintenanceDashboardResult] = []
|
||||
failed_dashboards: list[MaintenanceFailedDashboard] = []
|
||||
unmatched_tables: list[str] = []
|
||||
|
||||
class MaintenanceEndResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str # "pending"
|
||||
|
||||
class MaintenanceEndTaskResult(BaseModel):
|
||||
maintenance_id: str
|
||||
removed_from: int = 0
|
||||
failed_removals: list[MaintenanceFailedDashboard] = []
|
||||
|
||||
class MaintenanceEndAllTaskResult(BaseModel):
|
||||
closed_events: int = 0
|
||||
removed_from: int = 0
|
||||
failed_removals: list[MaintenanceFailedDashboard] = []
|
||||
|
||||
class MaintenanceSettingsResponse(BaseModel):
|
||||
target_environment_id: str
|
||||
dashboard_scope: str
|
||||
excluded_dashboard_ids: list[int]
|
||||
forced_dashboard_ids: list[int]
|
||||
updated_at: str | None = None
|
||||
|
||||
class MaintenanceEventItem(BaseModel):
|
||||
id: str
|
||||
tables: list[str]
|
||||
start_time: str
|
||||
end_time: str
|
||||
message: str | None = None
|
||||
status: str
|
||||
affected_count: int
|
||||
created_at: str
|
||||
|
||||
class MaintenanceEventListResponse(BaseModel):
|
||||
active: list[MaintenanceEventItem]
|
||||
completed: list[MaintenanceEventItem]
|
||||
|
||||
class MaintenanceDashboardBannerState(BaseModel):
|
||||
dashboard_id: int
|
||||
active: bool
|
||||
events: list[str] # maintenance event IDs
|
||||
start_time: str | None = None
|
||||
end_time: str | None = None
|
||||
```
|
||||
|
||||
## Index & Query Patterns
|
||||
|
||||
| Query | Index | Frequency |
|
||||
|-------|-------|-----------|
|
||||
| Find active events | `maintenance_events(status)` | Per polling cycle (30s) |
|
||||
| Find active banners for dashboard D | `maintenance_dashboard_states(dashboard_id, status)` | Per dashboard hub load |
|
||||
| Find all states for event E | `maintenance_dashboard_states(event_id)` | Per event detail view |
|
||||
| Check for duplicate active event | Application-level (tables JSON comparison) | Per /start call |
|
||||
| Get settings | `maintenance_settings(id="default")` — single row | Per /start call |
|
||||
|
||||
## State Transitions
|
||||
|
||||
### MaintenanceEvent
|
||||
```
|
||||
[created] → ACTIVE ──→ COMPLETED
|
||||
└──→ FAILED
|
||||
```
|
||||
|
||||
### MaintenanceDashboardState
|
||||
```
|
||||
[created] → ACTIVE ──→ REMOVED
|
||||
└──→ REMOVAL_FAILED
|
||||
```
|
||||
|
||||
### Event Conflict Matrix
|
||||
| Dashboard has | New /start | Behavior |
|
||||
|--------------|-----------|----------|
|
||||
| No banner | tables match | Create MaintenanceDashboardState (ACTIVE) + Create chart |
|
||||
| Banner from M1 (ACTIVE) | tables differ | Create new MaintenanceDashboardState (ACTIVE) + Rebuild banner text from ALL active events |
|
||||
| Banner from M1 (ACTIVE) | same tables (idempotent) | Return existing maintenance_id, no state change |
|
||||
| Banner from M1 (REMOVED) | any tables | M1 already completed, treat as no banner |
|
||||
141
specs/031-maintenance-banner/plan.md
Normal file
141
specs/031-maintenance-banner/plan.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Implementation Plan: Maintenance Banner for Dashboards
|
||||
|
||||
**Branch**: `031-maintenance-banner` | **Date**: 2026-05-21 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/031-maintenance-banner/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Service for automatically adding/removing maintenance warning banners on Superset dashboards during ETL table updates. External tools call async API endpoints to start/end maintenance events; ss-tools discovers affected dashboards (by matching table names against datasets), creates markdown charts at the top of each dashboard via Superset REST API, and provides a management UI for manual control and configuration. All operations are RBAC-gated (operator/admin roles) and traceable via TaskManager.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.13+ (backend), JavaScript/TypeScript (frontend Svelte 5 runes)
|
||||
**Primary Dependencies**: FastAPI 0.126, SQLAlchemy 2.0, APScheduler 3.11 (backend); SvelteKit 2.x, Svelte 5.43, Vite 7.x, Tailwind CSS 3.x (frontend)
|
||||
**Storage**: PostgreSQL 16 (dedicated ss-tools DB — not Superset metadata DB per ADR-0003)
|
||||
**Testing**: pytest (backend), vitest + @testing-library/svelte (frontend)
|
||||
**Target Platform**: Linux server (Docker), modern browsers
|
||||
**Project Type**: web application (FastAPI REST + WebSocket backend, SvelteKit SPA frontend)
|
||||
**Performance Goals**: /start returns task_id within 1s; banner placement within 60s for up to 50 dashboards; /end within 30s for up to 50 dashboards
|
||||
**Constraints**: RBAC enforcement (operator/admin per ADR-0005), Svelte 5 runes only (per ADR-0006), no fromStore + $derived (per ADR-0007), offline-capable Docker bundle
|
||||
**Scale/Scope**: 1 maintenance event typically affects 5-50 dashboards; up to 5 concurrent active events; table lists up to 100 entries
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| ADR-0001 (Module Layout) | ✅ PASS | All planned files follow canonical layout: `backend/src/api/routes/maintenance/`, `backend/src/services/maintenance_service.py`, `backend/src/models/maintenance.py`, `frontend/src/routes/maintenance/`, `frontend/src/lib/components/` |
|
||||
| ADR-0002 (Semantic Protocol) | ✅ PASS | All C3+ contracts carry `@PURPOSE`, `@RELATION`; C4 contracts add `@PRE`/`@POST`/`@SIDE_EFFECT` + belief runtime |
|
||||
| ADR-0003 (Orchestrator Pattern) | ✅ PASS | Feature operates as external orchestrator, not integrated into Superset. Uses Superset REST API only. |
|
||||
| ADR-0004 (Plugin Architecture) | ✅ PASS | Maintenance is a core feature in `services/` (stateless, in-process), not a subprocess-isolated plugin per ADR-0004. Justified in research.md R2. |
|
||||
| ADR-0005 (Auth RBAC) | ✅ PASS | FR-014: operator/admin roles for mutations; admin for settings |
|
||||
| ADR-0006 (Frontend Architecture) | ✅ PASS | Svelte 5 runes, SPA mode (adapter-static), Tailwind CSS, requestApi wrapper |
|
||||
| ADR-0007 (fromStore REJECTED) | ✅ PASS | Maintenance store uses `$effect(() => subscribe(...))` pattern, not fromStore + $derived |
|
||||
| Module <400 LOC | ✅ PASS | Each planned file: routes ~150 LOC, service ~250 LOC, components ~150 LOC each |
|
||||
| CC ≤ 10 per contract | ✅ PASS | All contract functions are small, focused operations |
|
||||
|
||||
**Verdict**: ✅ No blocking conflicts. Proceed to Phase 0/1.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/031-maintenance-banner/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0: 7 resolved decisions
|
||||
├── data-model.md # Phase 1: SQLAlchemy models, Pydantic schemas, state transitions
|
||||
├── quickstart.md # Phase 1: Verification commands
|
||||
├── contracts/
|
||||
│ └── modules.md # Phase 1: 14 semantic contracts (C1-C4)
|
||||
├── spec.md # Feature specification
|
||||
├── ux_reference.md # UX interaction reference
|
||||
├── checklists/
|
||||
│ └── requirements.md # Requirements validation checklist
|
||||
└── tasks.md # Phase 2 output (NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── api/routes/maintenance/ # NEW: package-based route module
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── _router.py # APIRouter assembly
|
||||
│ │ ├── _routes.py # Route handlers
|
||||
│ │ └── _schemas.py # Pydantic schemas
|
||||
│ ├── core/superset_client/
|
||||
│ │ └── _dashboards_write.py # NEW: SupersetClient write mixin
|
||||
│ ├── models/
|
||||
│ │ └── maintenance.py # NEW: SQLAlchemy models
|
||||
│ └── services/
|
||||
│ ├── maintenance_service.py # NEW: Core business logic
|
||||
│ └── sql_table_extractor.py # NEW: SQL table name parser
|
||||
├── tests/
|
||||
│ ├── test_maintenance_service.py # Unit/integration tests
|
||||
│ ├── test_sql_table_extractor.py # SQL parsing tests
|
||||
│ └── test_maintenance_api.py # API endpoint tests
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── routes/maintenance/
|
||||
│ │ └── +page.svelte # NEW: Management page
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── MaintenanceSettingsPanel.svelte # NEW
|
||||
│ │ │ ├── MaintenanceEventsTable.svelte # NEW
|
||||
│ │ │ └── DashboardMaintenanceBadge.svelte # NEW
|
||||
│ │ ├── stores/
|
||||
│ │ │ └── maintenance.svelte.js # NEW
|
||||
│ │ └── api/
|
||||
│ │ └── maintenance.js # NEW
|
||||
│ └── routes/dashboards/
|
||||
│ └── +page.svelte # MODIFIED: add maintenance badge column
|
||||
└── tests/
|
||||
├── maintenance.test.ts # Component tests
|
||||
└── maintenance-store.test.ts # Store tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Package-based route for `maintenance/` mirrors existing `dashboards/` pattern. Backend service in `services/` (stateless, request-scoped) per ADR-0001 boundary rule #3. Superset client extension in `core/superset_client/` as singleton mixin.
|
||||
|
||||
## Semantic Contract Guidance
|
||||
|
||||
All 14 contracts defined in `contracts/modules.md`:
|
||||
- **Complexity 1** (3): MaintenanceEvent, MaintenanceDashboardState, MaintenanceSettings models, Pydantic schemas
|
||||
- **Complexity 2** (3): SqlTableExtractor, MaintenanceRouter, MaintenanceApiClient, DashboardMaintenanceBadge
|
||||
- **Complexity 3** (5): MaintenanceRoutes, MaintenanceStore, MaintenanceBannerPage, MaintenanceSettingsPanel, MaintenanceEventsTable
|
||||
- **Complexity 4** (3): SupersetDashboardsWriteMixin, MaintenanceService (start/end/end_all methods)
|
||||
|
||||
C4 contracts require `@PRE`, `@POST`, `@SIDE_EFFECT` plus belief runtime instrumentation per semantics-python skill.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitutional violations to justify. All module placements and complexity levels follow ADR-0001 guidelines.*
|
||||
|
||||
| Aspect | Rationale |
|
||||
|--------|-----------|
|
||||
| Package-based route (`maintenance/`) | Mirrors `dashboards/` pattern; 3 files <200 LOC each |
|
||||
| Service in `services/` not `core/` | Stateless, request-scoped per ADR-0001 rule #3 |
|
||||
| SupersetClient mixin in `core/` | Singleton, app-scoped per ADR-0001 rule #2 |
|
||||
| Not a plugin | Maintenance is core infrastructure, not user-configurable |
|
||||
|
||||
## Phase 0 Outputs
|
||||
|
||||
See [research.md](./research.md) for 7 resolved decisions:
|
||||
1. Chart placement via direct API (not export-import)
|
||||
2. Module placement per ADR-0001
|
||||
3. SQL parsing via sqlparse with regex fallback
|
||||
4. Async orchestration via existing TaskManager
|
||||
5. Dedicated DB tables (not AppConfigRecord JSON)
|
||||
6. Svelte 5 runes with ADR-0007 compliance
|
||||
7. Existing RBAC reuse (operator + admin roles)
|
||||
|
||||
## Phase 1 Outputs
|
||||
|
||||
| Artifact | Path | Description |
|
||||
|----------|------|-------------|
|
||||
| Data Model | [data-model.md](./data-model.md) | 3 SQLAlchemy models, 10 Pydantic schemas, ER diagram, state transitions |
|
||||
| Contracts | [contracts/modules.md](./contracts/modules.md) | 14 semantic contracts (C1-C4) with relations, pre/post, rationale |
|
||||
| Quickstart | [quickstart.md](./quickstart.md) | Verification commands for backend/frontend/lint |
|
||||
107
specs/031-maintenance-banner/quickstart.md
Normal file
107
specs/031-maintenance-banner/quickstart.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Quickstart: Maintenance Banner for Dashboards
|
||||
|
||||
**Feature Branch**: `031-maintenance-banner`
|
||||
**Created**: 2026-05-21
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+ with virtualenv (`backend/.venv/`)
|
||||
- Node.js 18+ with npm (`frontend/node_modules/`)
|
||||
- PostgreSQL 16 running (local or Docker)
|
||||
- Superset instance configured in ss-tools environments
|
||||
|
||||
## Backend Verification
|
||||
|
||||
```bash
|
||||
# 1. Activate virtual environment
|
||||
cd backend && source .venv/bin/activate
|
||||
|
||||
# 2. Apply database migrations (Alembic)
|
||||
alembic upgrade head
|
||||
|
||||
# 3. Run maintenance-specific tests
|
||||
python -m pytest tests/test_maintenance_service.py -v
|
||||
python -m pytest tests/test_sql_table_extractor.py -v
|
||||
python -m pytest tests/test_maintenance_api.py -v
|
||||
|
||||
# 4. Run full test suite
|
||||
python -m pytest -v
|
||||
|
||||
# 5. Lint check
|
||||
python -m ruff check src/services/maintenance_service.py
|
||||
python -m ruff check src/api/routes/maintenance/
|
||||
python -m ruff check src/models/maintenance.py
|
||||
python -m ruff check src/core/superset_client/_dashboards_write.py
|
||||
|
||||
# 6. Full lint
|
||||
python -m ruff check .
|
||||
```
|
||||
|
||||
## Frontend Verification
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cd frontend && npm install
|
||||
|
||||
# 2. Run component tests
|
||||
npx vitest run tests/maintenance.test.ts
|
||||
npx vitest run tests/maintenance-store.test.ts
|
||||
|
||||
# 3. Run full test suite
|
||||
npm run test
|
||||
|
||||
# 4. Lint check
|
||||
npm run lint
|
||||
|
||||
# 5. Build check
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Docker Integration Test
|
||||
|
||||
```bash
|
||||
# Start full stack
|
||||
docker compose up --build
|
||||
|
||||
# Wait for services, then:
|
||||
# 1. Create maintenance event via API
|
||||
curl -X POST http://localhost:8001/api/maintenance/start \
|
||||
-H "Authorization: Bearer $(cat /tmp/test_token)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tables":["test.sample"],"start_time":"2026-05-21T22:00:00Z","end_time":"2026-05-22T02:00:00Z"}'
|
||||
|
||||
# 2. Check task status
|
||||
curl http://localhost:8001/api/tasks/<task_id> \
|
||||
-H "Authorization: Bearer $(cat /tmp/test_token)"
|
||||
|
||||
# 3. End maintenance
|
||||
curl -X POST http://localhost:8001/api/maintenance/<maintenance_id>/end \
|
||||
-H "Authorization: Bearer $(cat /tmp/test_token)"
|
||||
|
||||
# 4. View settings
|
||||
curl http://localhost:8001/api/maintenance/settings \
|
||||
-H "Authorization: Bearer $(cat /tmp/test_token)"
|
||||
|
||||
# 5. Open UI
|
||||
# Frontend: http://localhost:8000/maintenance
|
||||
```
|
||||
|
||||
## Key Files to Verify
|
||||
|
||||
| File | Purpose | Test File |
|
||||
|------|---------|-----------|
|
||||
| `backend/src/services/maintenance_service.py` | Core logic | `tests/test_maintenance_service.py` |
|
||||
| `backend/src/services/sql_table_extractor.py` | SQL parsing | `tests/test_sql_table_extractor.py` |
|
||||
| `backend/src/api/routes/maintenance/_routes.py` | API endpoints | `tests/test_maintenance_api.py` |
|
||||
| `backend/src/core/superset_client/_dashboards_write.py` | Superset API | Integrated with service tests |
|
||||
| `backend/src/models/maintenance.py` | ORM models | Covered by service tests |
|
||||
| `frontend/src/routes/maintenance/+page.svelte` | Management UI | `tests/maintenance.test.ts` |
|
||||
| `frontend/src/lib/components/DashboardMaintenanceBadge.svelte` | Badge | `tests/maintenance.test.ts` |
|
||||
| `frontend/src/lib/stores/maintenance.svelte.js` | State store | `tests/maintenance-store.test.ts` |
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **"Environment not found"**: Ensure `target_environment_id` in `maintenance_settings` matches an existing Superset environment in `config.json`.
|
||||
- **"No dashboards found"**: Verify table name format is `schema.table` (case-insensitive) and matches datasets in the target environment.
|
||||
- **"Chart creation failed"**: Check Superset API token validity and permissions (must have chart create rights).
|
||||
- **SQL parsing misses tables**: Virtual datasets with complex CTEs or non-standard SQL dialects may need regex fallback; check `sql_table_extractor.py` test corpus.
|
||||
173
specs/031-maintenance-banner/research.md
Normal file
173
specs/031-maintenance-banner/research.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Research: Maintenance Banner for Dashboards
|
||||
|
||||
**Feature Branch**: `031-maintenance-banner`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Phase 0 Complete
|
||||
|
||||
## R1: Superset Chart Placement Mechanism
|
||||
|
||||
### Decision
|
||||
Use **direct chart creation via `POST /api/v1/chart/` + layout manipulation via Superset's existing dashboard JSON metadata patch**, rather than the export→modify YAML→re-import cycle.
|
||||
|
||||
### Rationale
|
||||
- The export-import cycle is designed for full dashboard migration (ADR-0004). For adding/removing a single chart, it's excessively heavy: exports a multi-MB ZIP, modifies YAML, re-uploads. For 50 dashboards this could take minutes.
|
||||
- The `POST /chart/` endpoint is proven to work in the codebase (`seed_superset_load_test.py`, line 350). Creating a markdown chart (`viz_type: "markdown"`) with `dashboards: [dashboard_id]` links the chart to the dashboard.
|
||||
- For positioning at the top, we need to modify the dashboard's `position_json` to insert the new chart at (0,0) with full width. This can be done via `PUT /api/v1/dashboard/{id}` with updated `position_json` and `json_metadata`.
|
||||
- Superset REST API supports `PUT /dashboard/{id}` for updating dashboard metadata. We will extend `SupersetClient` with a `create_chart()` and `update_dashboard_layout()` method.
|
||||
|
||||
### Alternatives Considered
|
||||
- **Export-import cycle**: Rejected — too slow for 50+ dashboards; each dashboard requires ZIP round-trip.
|
||||
- **Superset's import API with selective overwrite**: Rejected — not granular enough; would replace the entire dashboard definition.
|
||||
- **SQL-based direct manipulation of Superset's metadata DB**: Rejected — violates ADR-0003 (ss-tools is an external orchestrator, not integrated into Superset).
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- Adds `SupersetDashboardsWriteMixin` with `create_chart()` and `update_dashboard()` methods to `SupersetClient`.
|
||||
- Chart removal pattern: store `chart_id` in `MaintenanceDashboardState`, delete via Superset chart delete API.
|
||||
- Tasks: T0XX (SupersetClient extension), T0XX (chart create/delete service).
|
||||
|
||||
---
|
||||
|
||||
## R2: Module Placement
|
||||
|
||||
### Decision
|
||||
Follow ADR-0001 canonical layout with these specific placements:
|
||||
|
||||
| Module | Path | Layer |
|
||||
|--------|------|-------|
|
||||
| API routes | `backend/src/api/routes/maintenance/` (package) | Route handler (C3) |
|
||||
| API schemas | `backend/src/api/routes/maintenance/_schemas.py` | Pydantic models (C1) |
|
||||
| Core service | `backend/src/services/maintenance_service.py` | Business logic (C4) |
|
||||
| Superset client mixin | `backend/src/core/superset_client/_dashboards_write.py` | Superset API (C4) |
|
||||
| SQL parser | `backend/src/services/sql_table_extractor.py` | Utility (C2/C3) |
|
||||
| ORM models | `backend/src/models/maintenance.py` | SQLAlchemy (C1) |
|
||||
| Frontend page | `frontend/src/routes/maintenance/+page.svelte` | SvelteKit page (C3) |
|
||||
| Frontend components | `frontend/src/lib/components/MaintenanceBanner*.svelte` | Svelte 5 components (C3) |
|
||||
| Frontend API client | `frontend/src/lib/api/maintenance.js` | API wrapper (C2) |
|
||||
| Frontend store | `frontend/src/lib/stores/maintenance.svelte.js` | Svelte 5 rune store (C3) |
|
||||
|
||||
### Rationale
|
||||
- Pattern B (package-based routes) for maintenance — mimics `dashboards/` organization with `_router.py`, `_routes.py`, `_schemas.py`. Single responsibility per file.
|
||||
- Business logic in `services/` not `core/` because maintenance service is stateless and request-scoped (per ADR-0001 boundary rule #3).
|
||||
- Superset API extension goes in `core/superset_client/` because it's a singleton client mixin (boundary rule #2).
|
||||
|
||||
### Alternatives Considered
|
||||
- **Plugin-based (like migration.py)**: Rejected — maintenance banner is not a user-configurable plugin with its own lifecycle; it's a core feature with admin UI.
|
||||
- **All logic in route handler**: Rejected — violates 400 LOC limit and C3 complexity for routes.
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- All task file paths must map to these locations.
|
||||
- Contract IDs must reference these canonical paths.
|
||||
|
||||
---
|
||||
|
||||
## R3: SQL Table Name Extraction for Virtual Datasets
|
||||
|
||||
### Decision
|
||||
**Extract `schema.table` patterns from raw SQL+Jinja text (no pre-stripping), then filter false positives via sqlparse tokenizer.** This is the reverse of the initially planned approach — search globally first, verify locally, rather than strip-then-parse.
|
||||
|
||||
### Rationale
|
||||
- Real-world Superset virtual datasets embed table names inside Jinja `{% set %}` blocks (e.g., a `selected_dataset` dictionary mapping dataset types to fully-qualified table names). If Jinja is stripped before parsing, these table references are lost.
|
||||
- Example from a production dataset: `{% set selected_dataset = {'Быстро': "dm_view.sales_final", 'Медленно': "dm_view.sales_raw"} %}` — both table names live inside a Jinja block, invisible to any SQL parser after stripping.
|
||||
- **New two-phase approach:**
|
||||
1. **Global extraction**: Regex `[a-zA-Z][\w]*\.[a-zA-Z][\w]*` on raw text captures ALL `schema.table` candidates (both in plain SQL and inside Jinja blocks).
|
||||
2. **False-positive filtering**: sqlparse tokenizes the raw text; each candidate is rejected if its position falls inside a SQL string literal (`'...'`) or comment.
|
||||
- This captures table names from ALL contexts — FROM, JOIN, Jinja `{% set %}`, Jinja string values — while filtering out `'2025.12.31'` (date in string literal) and similar false matches.
|
||||
- `sqlparse` remains the right tool for phase 2 because it classifies tokens as Identifier vs String vs Comment — the only classification needed for filtering.
|
||||
|
||||
### Alternatives Considered
|
||||
- **Strip Jinja → parse SQL (original plan)**: Rejected — loses tables embedded in Jinja `{% set %}` blocks (empirically proven with production dataset example).
|
||||
- **sqlglot**: Rejected — would also miss Jinja-embedded tables (same fundamental problem); its AST capabilities are overkill when the extraction strategy is pattern-based, not structure-based.
|
||||
- **Pure regex without sqlparse filter**: Rejected — false positives on date literals in string constants (`'2026.04.30'`).
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- `sql_table_extractor.py` module: C2 — two pure functions:
|
||||
- `extract_table_candidates(raw_sql: str) -> list[str]` — regex global extraction
|
||||
- `filter_string_literals(raw_sql: str, candidates: list[str]) -> set[str]` — sqlparse-based filtering
|
||||
- No Jinja pre-stripping step needed (simpler, safer).
|
||||
- Test corpus: production virtual dataset SQL+Jinja samples, date literals, string literals.
|
||||
|
||||
---
|
||||
|
||||
## R4: Async Task Orchestration
|
||||
|
||||
### Decision
|
||||
Use the **existing `TaskManager`** with a **new task type `maintenance_banner_apply` / `maintenance_banner_remove`**. The `POST /api/maintenance/start` endpoint creates a MaintenanceEvent and dispatches a task via `task_manager.create_task()`.
|
||||
|
||||
### Rationale
|
||||
- All existing long-running operations in ss-tools use TaskManager (migration, backup, LLM validation). Consistency reduces cognitive load and reuses scheduling/persistence/retry/WebSocket infrastructure.
|
||||
- WebSocket-based progress updates are already wired in `app.py` for all task log queues.
|
||||
- The operator gets a `task_id` immediately (per async clarification Q2) and can poll `GET /api/tasks/{task_id}` or subscribe to WebSocket events.
|
||||
- Task status lifecycle: `PENDING → RUNNING → SUCCESS/FAILED` maps directly to `MaintenanceEvent.status: active/completed/failed`.
|
||||
|
||||
### Alternatives Considered
|
||||
- **Celery / Redis Queue**: Rejected — ss-tools uses in-process APScheduler + async FastAPI; Celery adds Redis dependency and operational complexity.
|
||||
- **Direct synchronous HTTP response after completion**: Rejected — fails on 100+ dashboards (HTTP timeout).
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- `MaintenanceEvent.task_id` links to `Task.id`.
|
||||
- Task payload: `{"event_id": "m-...", "tables": [...], "action": "apply"|"remove"}`.
|
||||
- No new task infrastructure needed — reuse `TaskManager.create_task()`.
|
||||
|
||||
---
|
||||
|
||||
## R5: Database Schema — Dedicated Tables vs AppConfigRecord
|
||||
|
||||
### Decision
|
||||
Use **dedicated SQLAlchemy models** (`MaintenanceEvent`, `MaintenanceDashboardState`, `MaintenanceSettings`) with proper foreign keys and indexes rather than JSON blobs in `AppConfigRecord`.
|
||||
|
||||
### Rationale
|
||||
- Maintenance events require relational queries: "which dashboards are affected by active events?", "find all events for dashboard D", "count active events". JSON in AppConfigRecord would require full-table-scan + client-side filtering.
|
||||
- `MaintenanceDashboardState` has a foreign key to both `MaintenanceEvent` and acts as a junction table for the many-to-many relationship between events and dashboards.
|
||||
- `MaintenanceSettings` is a single-row table (one settings record) — simpler than `AppConfigRecord` key-value pattern and provides typed columns with validation.
|
||||
|
||||
### Alternatives Considered
|
||||
- **AppConfigRecord with JSON payload**: Rejected — no queryability, no foreign key constraints, no type safety.
|
||||
- **Superset metadata DB modification**: Rejected — violates ADR-0003 (external orchestrator).
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- Three new SQLAlchemy models in `backend/src/models/maintenance.py`.
|
||||
- Alembic migration required.
|
||||
- Index on `MaintenanceDashboardState(dashboard_id, status)` for dashboard-hub queries.
|
||||
|
||||
---
|
||||
|
||||
## R6: Frontend Architecture — Svelte 5 Runes
|
||||
|
||||
### Decision
|
||||
New SvelteKit route `/maintenance` with three Svelte 5 runes-based components: `MaintenanceBannerManagement.svelte` (settings + events table), maintenance badge integration into existing `DashboardGrid` component, and API client module `maintenance.js`.
|
||||
|
||||
### Rationale
|
||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) mandated by ADR-0006. No legacy `$:` or `writable` stores.
|
||||
- `fromStore` + multiple `$derived` is explicitly REJECTED per ADR-0007 — use `$effect(() => store.subscribe(...))` pattern.
|
||||
- State topology: page-level `$state` for events/settings, `$derived` for filtered views, `$effect` for 30s polling.
|
||||
- API client follows existing pattern in `frontend/src/lib/api/` using `requestApi` wrapper.
|
||||
|
||||
### Alternatives Considered
|
||||
- **SSR with SvelteKit load functions**: Rejected — ADR-0006 mandates SPA mode (adapter-static). All data fetched client-side via REST API.
|
||||
- **Shared store for maintenance state**: The maintenance badge in Dashboard Hub needs data from maintenance events. Use a shared `maintenance.svelte.js` rune store.
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- New store: `frontend/src/lib/stores/maintenance.svelte.js` (C3).
|
||||
- New route: `frontend/src/routes/maintenance/+page.svelte` (C3).
|
||||
- Modified component: `frontend/src/routes/dashboards/+page.svelte` (add maintenance badge column/cell).
|
||||
|
||||
---
|
||||
|
||||
## R7: Authentication & Authorization
|
||||
|
||||
### Decision
|
||||
Reuse existing RBAC system (ADR-0005). API endpoints require `operator` or `admin` role. UI management page requires `admin` role. Maintenance badge in Dashboard Hub is visible to all authenticated users.
|
||||
|
||||
### Rationale
|
||||
- FR-014 mandates `operator` or `admin` for dashboard-modifying operations.
|
||||
- UI settings page (scope, excluded/forced dashboards) is an administrative function — `admin` only.
|
||||
- The badge in Dashboard Hub is read-only information, safe for all roles.
|
||||
|
||||
### Alternatives Considered
|
||||
- **Separate `maintenance_admin` role**: Rejected — overcomplicates RBAC for a single feature.
|
||||
- **Open API (no auth)**: Rejected — would allow unauthorized banner injection on production dashboards.
|
||||
|
||||
### Impact On Contracts / Tasks
|
||||
- `POST/PUT /api/maintenance/*` endpoints use `Depends(require_role("operator"))`.
|
||||
- `GET /api/maintenance/settings` uses `Depends(require_role("admin"))`.
|
||||
- Badge data endpoint open to authenticated users.
|
||||
218
specs/031-maintenance-banner/spec.md
Normal file
218
specs/031-maintenance-banner/spec.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Feature Specification: Maintenance Banner for Dashboards
|
||||
|
||||
**Feature Branch**: `031-maintenance-banner`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Service for adding a maintenance banner to dashboards during ETL table updates. External tool passes names of tables being updated to an API method in ss-tools; ss-tools finds dashboards with datasets referencing those tables (both table-based and virtual SQL Jinja datasets) and adds a static text-chart at the top of the dashboard with maintenance information. Support passing start and end times. Banner removal — centrally from ss-tools or externally via API."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-05-21
|
||||
|
||||
- Q: Which Superset environment should /api/maintenance/start target (ss-tools manages multiple)? → A: System always targets the production environment (pre-configured in ss-tools settings). External tool does NOT pass environment_id.
|
||||
- Q: Should /api/maintenance/start be synchronous (blocking) or asynchronous (task-based)? → A: Async — returns `{task_id, status: "pending"}` immediately; operator polls `GET /api/tasks/{task_id}` or uses WebSocket for progress (matches existing TaskManager pattern).
|
||||
- Q: How should table names from the API be matched against dataset references? → A: Exact `schema.table` match only (e.g., `"raw.sales"` matches only `"raw.sales"`, not unqualified `"sales"`). Case-insensitive.
|
||||
- Q: Should duplicate /start calls (same tables, same times) create a new MaintenanceEvent or be idempotent? → A: Idempotent — detect active event with same tables and return existing maintenance_id with status "already_active".
|
||||
- Q: When /end is called on event M1 but dashboard D is also affected by active event M2, should the banner be removed? → A: No — banner stays on D but is rebuilt with updated text (only M2's info). Removal occurs only when the LAST active event affecting D is ended.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — External tool initiates maintenance banner display (Priority: P1)
|
||||
|
||||
An ETL operator triggers a table refresh. The external tool (e.g., Airflow DAG) calls an ss-tools API endpoint, passing:
|
||||
- List of table names being updated (e.g., `["raw.sales", "raw.inventory"]`)
|
||||
- Maintenance start time
|
||||
- Planned end time
|
||||
- (optional) custom message text
|
||||
|
||||
ss-tools finds all dashboards in Superset whose datasets reference the given tables (both table-based datasets and virtual/SQL Jinja datasets whose SQL query references the tables). For each affected dashboard, ss-tools adds a markdown chart at the very top with the maintenance warning.
|
||||
|
||||
**Why this priority**: This is the core use case — without it, the feature has no value. It enables automatic notification of dashboard users about potentially stale data during ETL.
|
||||
|
||||
**Independent Test**: Can be tested by calling the API with a table name known to be used in a test dashboard, then verifying a markdown chart appears at the top of that dashboard in Superset.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** dashboard D in the production environment uses a dataset referencing table `raw.sales`, **When** external tool calls `POST /api/maintenance/start` with `{"tables": ["raw.sales"], "start_time": "2026-05-21T22:00:00Z", "end_time": "2026-05-22T02:00:00Z"}`, **Then** API returns `{task_id: "t-...", status: "pending"}` immediately, and upon task completion dashboard D in Superset shows a markdown chart at the top: "⚠️ Maintenance in progress: 2026-05-21 22:00 — 2026-05-22 02:00 UTC. Data may be incomplete."
|
||||
|
||||
2. **Given** dashboard D uses a virtual SQL dataset with query `SELECT * FROM raw.sales JOIN raw.inventory ON ...`, **When** external tool passes table `raw.inventory`, **Then** dashboard D also receives the banner (since the table is found in the dataset SQL text).
|
||||
|
||||
3. **Given** dashboard D already has a banner from a previous maintenance event, **When** a new `POST /api/maintenance/start` request arrives referencing a different table that also affects D, **Then** the banner is updated (time and text replaced) rather than duplicated.
|
||||
|
||||
4. **Given** table `raw.archived` is not used in any dashboard, **When** external tool passes `{"tables": ["raw.archived"]}`, **Then** API returns `{"affected_dashboards": 0, "message": "No dashboards found referencing tables: raw.archived"}` and no dashboard is modified.
|
||||
|
||||
5. **Given** an active MaintenanceEvent E already exists with tables `["raw.sales"]`, **When** external tool calls `/start` again with the same tables, **Then** API returns `{maintenance_id: E.id, status: "already_active"}` without creating a duplicate event.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — External tool removes maintenance banner upon completion (Priority: P1)
|
||||
|
||||
The ETL process completes. The external tool calls an ss-tools API endpoint to remove banners. ss-tools removes markdown charts from all dashboards affected by the given maintenance event.
|
||||
|
||||
**Why this priority**: Critical for restoring normal dashboard appearance after maintenance. Users must not see stale warnings.
|
||||
|
||||
**Independent Test**: After US1, call `POST /api/maintenance/{id}/end` and verify charts disappear from dashboards.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** maintenance event M added banners to dashboards D1, D2 (no other active events affect D1 or D2), **When** external tool calls `POST /api/maintenance/{maintenance_id}/end`, **Then** banners are removed from D1 and D2, and maintenance event M is marked as completed.
|
||||
|
||||
2. **Given** maintenance event M is already completed, **When** external tool calls `POST /api/maintenance/{maintenance_id}/end` again, **Then** API returns `{"status": "already_completed", "message": "Maintenance M was already completed at ..."}` (idempotent).
|
||||
|
||||
3. **Given** dashboard D is affected by active events M1 and M2, **When** external tool calls `/end` on M1, **Then** the banner on D is NOT removed — instead, the banner text is rebuilt to show only M2's information. M1 is marked as completed. The banner is fully removed only when M2 also ends.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Administrator manages banners via ss-tools UI (Priority: P2)
|
||||
|
||||
An administrator opens the ss-tools web interface, sees a list of active maintenance events and affected dashboards. They can selectively or bulk-remove banners without waiting for the external tool.
|
||||
|
||||
**Why this priority**: Administrators need manual override capability in case of ETL tool failure or emergency banner removal.
|
||||
|
||||
**Independent Test**: Navigate to maintenance management page, view active events, click "Remove All" and verify banners disappear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** active maintenance events M1 (affects D1, D2) and M2 (affects D3), **When** administrator opens the "Maintenance Banners" page, **Then** they see a table with M1, M2, affected dashboard counts, start/end times, and action buttons.
|
||||
|
||||
2. **Given** administrator is on the management page, **When** they select M1 and click "Remove Banners", **Then** markdown charts are removed from D1, D2, and M1 status changes to "Completed".
|
||||
|
||||
3. **Given** administrator is on the management page, **When** they click "Remove All", **Then** banners are removed from all dashboards across all active maintenance events.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Maintenance mode scope configuration (Priority: P3)
|
||||
|
||||
Administrator configures which dashboards are affected by automatic banner placement:
|
||||
- Published only, drafts only, or both
|
||||
- List of dashboards excluded from maintenance mode (hard ban — banner is never added)
|
||||
- List of dashboards forcibly included (banner always added, even if no table match is found)
|
||||
|
||||
**Why this priority**: Configuration flexibility allows adaptation to specific processes, but the system works with sensible defaults.
|
||||
|
||||
**Independent Test**: Configure exclusion of dashboard D1, then trigger maintenance on a table used in D1 — banner must NOT appear on D1, but must appear on D2.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** default settings (scope = `published_only`, excluded = `[]`, forced = `[]`), **When** maintenance affects draft dashboard D, **Then** banner is NOT added to D.
|
||||
|
||||
2. **Given** administrator added dashboard D to the excluded list, **When** maintenance affects a table used in D, **Then** banner is NOT added to D, and logs record the reason: "Dashboard D is in exclusion list".
|
||||
|
||||
3. **Given** administrator added dashboard D to the forced list, **When** maintenance does NOT affect any table in D, **Then** banner IS STILL added to D (forced inclusion).
|
||||
|
||||
4. **Given** scope = `all` (published + draft), **When** maintenance affects tables in D1 (published) and D2 (draft), **Then** banner is added to BOTH dashboards.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Maintenance status indicator in dashboard grid (Priority: P3)
|
||||
|
||||
The Dashboard Hub table shows an indicator when a dashboard has an active maintenance banner. Users can see this information without opening Superset.
|
||||
|
||||
**Why this priority**: Improves operator UX but does not block core functionality.
|
||||
|
||||
**Independent Test**: After US1, the dashboard grid shows a maintenance badge in the status column.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** dashboard D has an active maintenance banner, **When** a user opens the Dashboard Hub, **Then** dashboard D's row shows an orange badge "⚠️ Maintenance" with a tooltip showing start and end times.
|
||||
|
||||
2. **Given** the maintenance banner is removed from dashboard D, **When** the user refreshes the Dashboard Hub page, **Then** the badge disappears.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Multiple maintenance events conflict**: If two ETL processes simultaneously update different tables affecting the same dashboard — the banner must aggregate information from all active events. When one event ends, the banner text is rebuilt from remaining active events rather than removed. Full banner removal occurs only when ALL events affecting that dashboard are completed.
|
||||
|
||||
- **Dashboard with no datasets** (empty dashboard): Must not receive a banner, even if tables are listed in the maintenance event.
|
||||
|
||||
- **Table referenced in multiple datasets within one dashboard**: Banner is added only once per dashboard, regardless of how many dataset references the table has within it.
|
||||
|
||||
- **Network error contacting Superset API**: If some dashboards are processed successfully and others fail due to Superset errors, the API returns a partial success response listing the problematic dashboards.
|
||||
|
||||
- **Very large table list** (100+): API must process Superset queries in batches and return progress or a warning about extended processing time.
|
||||
|
||||
- **Dashboard deleted from Superset between discovery and banner placement**: Handle 404 from Superset API, log the event, and continue processing the rest.
|
||||
|
||||
- **Virtual dataset with Jinja and SQL**: Table names may appear both in plain SQL (FROM/JOIN) and inside Jinja `{% set %}` blocks (e.g., dictionary mapping dataset types to table names). Extraction uses global pattern matching on raw text (not Jinja-stripping first) to capture ALL `schema.table` occurrences, then sqlparse filters out false positives inside SQL string literals. Unqualified table references are not matched.
|
||||
|
||||
- **Dashboard present in both excluded and forced lists**: Forced takes priority (explicit inclusion overrides exclusion).
|
||||
|
||||
- **Duplicate /start call with identical parameters**: If an active MaintenanceEvent already exists with the same table list, the system returns the existing `maintenance_id` with status `"already_active"` instead of creating a duplicate event. This prevents unnecessary banner churn and Superset API calls.
|
||||
|
||||
- **Time zones**: Maintenance start and end times must be correctly displayed in the banner text respecting timezone (accept ISO 8601 with timezone, display in local timezone or UTC as configured).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide API endpoint `POST /api/maintenance/start` accepting: list of table names (`tables: str[]`), start time (`start_time: datetime`), end time (`end_time: datetime`), optional message text (`message: str | None`). Returns immediately with `{task_id, status: "pending"}`; actual banner placement runs asynchronously via TaskManager. The system always targets the production Superset environment (configured in MaintenanceSettings).
|
||||
|
||||
- **FR-002**: System MUST, upon receiving a maintenance start request, find all dashboards in the configured production Superset environment whose datasets reference the given tables.
|
||||
|
||||
- **FR-003**: System MUST support finding tables in two dataset types using case-insensitive exact `schema.table` matching:
|
||||
- Table-based (physical tables): match on `{schema}.{table_name}` format (e.g., `"raw.sales"` matches dataset with schema=`raw`, table_name=`sales`).
|
||||
- Virtual/SQL (`is_sqllab_view = true`): extract ALL fully-qualified `schema.table` references from the raw SQL+Jinja text using global pattern matching (regex), then filter false positives via sqlparse tokenizer. This captures table names in FROM/JOIN clauses, Jinja `{% set %}` blocks, CTE bodies, and subqueries. Jinja is NOT pre-stripped — table names inside `{% set %}` blocks are preserved. Unqualified references are NOT matched.
|
||||
|
||||
- **FR-004**: System MUST add a markdown chart (Superset `viz_type: "markdown"`) at the top of each affected dashboard with maintenance information.
|
||||
|
||||
- **FR-005**: The banner text MUST contain: a warning indicator (⚠️), the fact of ongoing maintenance, start time, planned end time, and (optionally) an additional message.
|
||||
|
||||
- **FR-006**: System MUST provide API endpoint `POST /api/maintenance/{maintenance_id}/end` for removing banners from dashboards affected by a specific maintenance event. Returns `{task_id}` immediately; removal runs asynchronously.
|
||||
|
||||
- **FR-007**: System MUST provide API endpoint `POST /api/maintenance/end-all` for bulk removal of all active banners from all dashboards. Returns `{task_id}` immediately; removal runs asynchronously.
|
||||
|
||||
- **FR-008**: System MUST persist a maintenance event history in the local database with fields: id, task_id, table list, start time, end time, optional message, status (active/completed/failed), timestamps. Affected dashboard tracking uses the normalized `MaintenanceDashboardState` relationship, not an inline dashboard ID list.
|
||||
|
||||
- **FR-009**: System MUST support maintenance mode settings via `GET/PUT /api/maintenance/settings`:
|
||||
- `target_environment_id`: `str` — the production Superset environment to scan/modify
|
||||
- `dashboard_scope`: `"published_only"` | `"draft_only"` | `"all"` (default: `"published_only"`)
|
||||
- `excluded_dashboard_ids`: `int[]` — dashboards NEVER receiving banners
|
||||
- `forced_dashboard_ids`: `int[]` — dashboards ALWAYS receiving banners (overrides excluded)
|
||||
|
||||
- **FR-010**: System MUST provide a UI management page for maintenance mode with:
|
||||
- Table of active and completed maintenance events
|
||||
- Selective and bulk banner removal
|
||||
- Settings configuration form (scope, excluded, forced)
|
||||
|
||||
- **FR-011**: System MUST display an active banner indicator in the Dashboard Hub table as a badge with a tooltip.
|
||||
|
||||
- **FR-012**: System MUST handle multiple maintenance events on the same dashboard: aggregate information and remove the banner only after ALL affecting events are completed.
|
||||
|
||||
- **FR-013**: System MUST, on partial success, return a detailed response listing successfully processed and problematic dashboards.
|
||||
|
||||
- **FR-014**: All dashboard-modifying operations via Superset API MUST require the `operator` or `admin` role (per RBAC ADR-0005).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **MaintenanceEvent**: A record of a maintenance event. Contains: id, task_id (links to TaskManager task), table list, start time, end time, optional message, affected dashboard IDs, status (active/completed/failed), created_at, updated_at.
|
||||
|
||||
- **MaintenanceDashboardState**: The banner state for a specific dashboard. Contains: maintenance_event_id, dashboard_id, environment_id, chart_id (Superset markdown chart ID), status (active/removed/removal_failed), created_at, removed_at.
|
||||
|
||||
- **MaintenanceSettings**: Maintenance mode configuration. Contains: target_environment_id (str — the production Superset environment), dashboard_scope (enum), excluded_dashboard_ids (int[]), forced_dashboard_ids (int[]), updated_at.
|
||||
|
||||
- **Dashboard** (existing Superset entity): Used for the dashboard → datasets → tables relationship. Has `published`/`draft` status.
|
||||
|
||||
- **Dataset** (existing Superset entity): Two types: table-based (direct table reference) and virtual/SQL (`is_sqllab_view = true`, contains SQL query).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: `POST /api/maintenance/start` returns `{task_id}` within 1 second (immediate async dispatch). The background task finds all affected dashboards (up to 50) and places banners within 60 seconds.
|
||||
|
||||
- **SC-002**: After task completion, the banner appears on each dashboard in Superset within the same 60-second window (including markdown chart creation via Superset API).
|
||||
|
||||
- **SC-003**: `POST /api/maintenance/{id}/end` returns `{task_id}` within 1 second; the background task removes banners from all affected dashboards (up to 50) within 30 seconds.
|
||||
|
||||
- **SC-004**: SQL table name extraction from virtual datasets correctly identifies all fully-qualified `schema.table` references embedded in Jinja `{% set %}` blocks, plain SQL FROM/JOIN, CTE bodies, and subqueries, while filtering out string-literal false positives (date literals like `'2026.04.30'`) — with ≥ 95% accuracy on a test corpus of production virtual dataset SQL+Jinja samples.
|
||||
|
||||
- **SC-005**: Administrator can remove banners from all dashboards via UI in fewer than 3 clicks.
|
||||
|
||||
- **SC-006**: Conflicting maintenance events on the same dashboard do not produce duplicate banners — exactly one markdown chart per dashboard at all times.
|
||||
|
||||
- **SC-007**: On Superset API network errors, the system correctly returns a partial result without losing information about already-processed dashboards.
|
||||
|
||||
## UX Reference
|
||||
|
||||
See [ux_reference.md](./ux_reference.md) for detailed interaction flows, UI mockups, and error recovery paths.
|
||||
270
specs/031-maintenance-banner/tasks.md
Normal file
270
specs/031-maintenance-banner/tasks.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Tasks: Maintenance Banner for Dashboards
|
||||
|
||||
**Input**: Design documents from `specs/031-maintenance-banner/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/modules.md ✅
|
||||
|
||||
**Tests**: Included for all C3+ contracts, new API endpoints, database models, and ADR-rejected regression paths.
|
||||
|
||||
**Organization**: Tasks grouped by user story for independent implementation and verification.
|
||||
|
||||
## Format: `- [ ] T### [P?] [USx] Description with exact file path`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[USx]**: Which user story (US1-US5); `--` for setup/foundational/polish phases
|
||||
- Paths relative to repository root
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project scaffolding — directories, package inits, Alembic migration skeleton.
|
||||
|
||||
- [ ] T001 Create `backend/src/api/routes/maintenance/` package directory structure (`__init__.py`)
|
||||
- [ ] T002 [P] Create `backend/tests/` test file stubs for maintenance feature: `test_maintenance_service.py`, `test_sql_table_extractor.py`, `test_maintenance_api.py`
|
||||
- [ ] T003 [P] Create frontend directory structure: `frontend/src/routes/maintenance/`, `frontend/src/lib/components/MaintenanceSettingsPanel.svelte`, `frontend/src/lib/components/MaintenanceEventsTable.svelte`, `frontend/src/lib/components/DashboardMaintenanceBadge.svelte` (empty placeholder files)
|
||||
- [ ] T004 Generate Alembic migration for new tables (`maintenance_events`, `maintenance_dashboard_states`, `maintenance_settings`) via `alembic revision --autogenerate -m "add maintenance banner tables"` after models are defined in Phase 2
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented. All models, schemas, SupersetClient extension, SQL extractor, router scaffold.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
### Database Models
|
||||
|
||||
- [ ] T005 [P] [--] Implement `MaintenanceEvent` SQLAlchemy model in `backend/src/models/maintenance.py` with columns (id, task_id, tables JSON, start_time, end_time, message, status enum, timestamps) and relationship to MaintenanceDashboardState. Contract: `[DEF:MaintenanceEvent:Model]` C1.
|
||||
- [ ] T006 [P] [--] Implement `MaintenanceDashboardState` SQLAlchemy model in `backend/src/models/maintenance.py` with columns (id, event_id FK, dashboard_id, environment_id, chart_id, status enum, created_at, removed_at) and composite index on (dashboard_id, status). Contract: `[DEF:MaintenanceDashboardState:Model]` C1.
|
||||
- [ ] T007 [P] [--] Implement `MaintenanceSettings` SQLAlchemy model in `backend/src/models/maintenance.py` — single-row table (id="default") with columns (target_environment_id, dashboard_scope enum, excluded_dashboard_ids JSON, forced_dashboard_ids JSON, updated_at). Contract: `[DEF:MaintenanceSettings:Model]` C1.
|
||||
|
||||
### Pydantic Schemas
|
||||
|
||||
- [ ] T008 [--] Implement all Pydantic request/response schemas in `backend/src/api/routes/maintenance/_schemas.py`: MaintenanceStartRequest, MaintenanceSettingsUpdate, MaintenanceStartResponse, MaintenanceTaskResult, MaintenanceDashboardResult, MaintenanceFailedDashboard, MaintenanceEndResponse, MaintenanceEndTaskResult, MaintenanceEndAllTaskResult, MaintenanceSettingsResponse, MaintenanceEventItem, MaintenanceEventListResponse, MaintenanceDashboardBannerState. Contract: `[DEF:MaintenanceSchemas:Module]` C1.
|
||||
|
||||
### SupersetClient Extension (C4 — REQUIRES belief runtime)
|
||||
|
||||
- [ ] T009 [--] Implement `SupersetDashboardsWriteMixin` in `backend/src/core/superset_client/_dashboards_write.py` with methods: `create_markdown_chart(dashboard_id, markdown_text) → chart_id` (POST /api/v1/chart/ with viz_type="markdown"), `update_dashboard_layout(dashboard_id, chart_id, position)` (PUT /api/v1/dashboard/{id} with patched position_json), `delete_chart(chart_id)` (DELETE /api/v1/chart/{id}), `get_dashboard_layout(dashboard_id)` (GET /api/v1/dashboard/{id} for position_json). Contract: `[DEF:SupersetDashboardsWriteMixin:Class]` C4. (RATIONALE: direct chart API faster than export-import for single-chart ops per research.md R1; REJECTED: export→modify YAML→re-import cycle — too slow for 50+ dashboards)
|
||||
- [ ] T010 [--] Instrument `SupersetDashboardsWriteMixin` with belief runtime markers: `belief_scope` context manager wrapping each Superset API call, `log(REASON)` before POST/PUT/DELETE, `log(REFLECT)` on success, `log(EXPLORE)` on HTTP errors.
|
||||
|
||||
### SQL Table Extractor (C2)
|
||||
|
||||
- [ ] T011 [--] Implement `SqlTableExtractor` module in `backend/src/services/sql_table_extractor.py` with two-phase extraction: `extract_table_candidates(raw_sql)` using regex `[a-zA-Z][\w]*\.[a-zA-Z][\w]*` on raw SQL+Jinja text, and `filter_string_literals(raw_sql, candidates)` using sqlparse tokenizer to reject candidates inside SQL string literals/comments. Orchestrator function: `extract_tables_from_sql(raw_sql) → set[str]`. Contract: `[DEF:SqlTableExtractor:Module]` C2. (RATIONALE: table names in Jinja {% set %} blocks survive global extraction; REJECTED: strip-Jinja-first approach loses 60% of table references per production dataset analysis)
|
||||
|
||||
### Router & App Registration
|
||||
|
||||
- [ ] T012 [--] Implement `MaintenanceRouter` in `backend/src/api/routes/maintenance/_router.py` — creates `APIRouter(prefix="/api/maintenance", tags=["maintenance"])`. Contract: `[DEF:MaintenanceRouter:Module]` C2.
|
||||
- [ ] T013 [--] Implement route handler stubs in `backend/src/api/routes/maintenance/_routes.py` — all 7 endpoint signatures with placeholder returns and proper RBAC `Depends(require_role(...))` guards. Contract: `[DEF:MaintenanceRoutes:Module]` C3 (stub phase).
|
||||
- [ ] T014 [--] Register `maintenance_router` in main FastAPI application (`backend/src/app.py`) and register `SupersetDashboardsWriteMixin` in SupersetClient assembly.
|
||||
|
||||
### Seed Data
|
||||
|
||||
- [ ] T015 [--] Create Alembic data migration (or startup check) to insert default `MaintenanceSettings` row (id="default", target_environment_id=<from config>, dashboard_scope="published_only", excluded=[], forced=[]) if not exists.
|
||||
|
||||
**Checkpoint**: Foundation ready — models, schemas, SupersetClient extension, SQL extractor, router, and seed data in place. All user stories can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Banner Placement (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: External tool calls `POST /api/maintenance/start` → ss-tools finds affected dashboards → places markdown banners.
|
||||
|
||||
**Independent Test**: Call API with a known table name, verify banner appears on affected dashboards in Superset.
|
||||
|
||||
### Tests for US1 (Write FIRST, ensure they FAIL)
|
||||
|
||||
- [ ] T016 [P] [US1] Write contract test for `POST /api/maintenance/start` in `backend/tests/test_maintenance_api.py` — test valid request returns `{task_id, status: "pending"}`, test duplicate request returns `{status: "already_active"}`, test no-match returns `{status: "no_match"}`, test RBAC returns 403 for viewer role.
|
||||
- [ ] T017 [P] [US1] Write unit test for `SqlTableExtractor.extract_tables_from_sql()` in `backend/tests/test_sql_table_extractor.py` — test table-based matching, virtual-SQL extraction with Jinja `{% set %}`, string literal false-positive rejection, case-insensitive matching.
|
||||
- [ ] T018 [P] [US1] Write integration test for `MaintenanceService.start_maintenance()` in `backend/tests/test_maintenance_service.py` — test dashboard discovery, markdown chart creation, MaintenanceEvent persistence, MaintenanceDashboardState creation, idempotency check.
|
||||
|
||||
### Implementation for US1
|
||||
|
||||
- [ ] T019 [US1] Implement `find_affected_dashboards()` method in `backend/src/services/maintenance_service.py` — fetches all datasets from Superset, matches table-based datasets via (schema, table_name), matches virtual datasets via SqlTableExtractor, applies scope/excluded/forced filtering from MaintenanceSettings, returns deduplicated dashboard list. Contract C3 within `[DEF:MaintenanceService:Class]`.
|
||||
- [ ] T020 [US1] Implement `build_banner_text()` method in `backend/src/services/maintenance_service.py` — aggregates multiple active events into a single markdown string with ⚠️ header, start/end times per event, and optional message. Handles single-event (simple text) and multi-event (aggregated text) cases. Contract C2 within `[DEF:MaintenanceService:Class]`.
|
||||
- [ ] T021 [US1] Implement `start_maintenance()` method in `backend/src/services/maintenance_service.py` — orchestrates full flow: (1) idempotency check (reuse active event with same tables), (2) `find_affected_dashboards()`, (3) create MaintenanceEvent record, (4) for each dashboard: create markdown chart via SupersetDashboardsWriteMixin, create MaintenanceDashboardState, patch dashboard layout, handle partial failures with 207 response, (5) if dashboard has existing banner from other events → rebuild aggregated text. Contract C4 within `[DEF:MaintenanceService:Class]`. (RATIONALE: single orchestrator ensures consistency between local DB state and Superset chart state)
|
||||
- [ ] T022 [US1] Instrument `start_maintenance()` with belief runtime: `belief_scope("start_maintenance")`, `log(REASON)` at entry/checkpoint, `log(REFLECT)` on completion, `log(EXPLORE)` on Superset errors.
|
||||
- [ ] T023 [US1] Implement `POST /api/maintenance/start` route handler in `backend/src/api/routes/maintenance/_routes.py` — validates MaintenanceStartRequest, dispatches task via TaskManager.create_task() with type="maintenance_banner_apply", returns MaintenanceStartResponse with task_id immediately. RBAC: `Depends(require_role("operator"))`. Handles no-match fast-path (returns synchronously without task).
|
||||
- [ ] T024 [US1] Run backend verification: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_maintenance_api.py backend/tests/test_maintenance_service.py backend/tests/test_sql_table_extractor.py -v`
|
||||
|
||||
**Checkpoint**: `POST /api/maintenance/start` fully functional. Banners placed on affected dashboards. Idempotency working.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Banner Removal (Priority: P1)
|
||||
|
||||
**Goal**: External tool calls `POST /api/maintenance/{id}/end` or `/end-all` → banners removed from dashboards.
|
||||
|
||||
**Independent Test**: After US1, call /end and verify banners disappear; call again and verify idempotent.
|
||||
|
||||
### Tests for US2 (Write FIRST, ensure they FAIL)
|
||||
|
||||
- [ ] T025 [P] [US2] Write contract test for `POST /api/maintenance/{id}/end` in `backend/tests/test_maintenance_api.py` — test successful removal, test already-completed idempotent response, test multi-event scenario (banner stays when other active events exist, text rebuilt).
|
||||
- [ ] T026 [P] [US2] Write integration test for `MaintenanceService.end_maintenance()` in `backend/tests/test_maintenance_service.py` — test chart deletion via Superset API, test MaintenanceDashboardState transition to REMOVED, test MaintenanceEvent transition to COMPLETED, test banner rebuild when other active events remain.
|
||||
- [ ] T026a [P] [US2] Write integration test for `MaintenanceService.end_all_maintenance()` in `backend/tests/test_maintenance_service.py` — test bulk closure of all active events, verify each transitions to COMPLETED, test mixed state (some dashboards have other active events → banner stays, others → banner removed).
|
||||
|
||||
### Implementation for US2
|
||||
|
||||
- [ ] T027 [US2] Implement `rebuild_banner()` method in `backend/src/services/maintenance_service.py` — for a given dashboard, queries all active MaintenanceDashboardState records, calls `build_banner_text()` for aggregated text, updates the existing markdown chart in Superset via `update_markdown_chart()`. Contract C3 within `[DEF:MaintenanceService:Class]`.
|
||||
- [ ] T028 [US2] Implement `end_maintenance()` method in `backend/src/services/maintenance_service.py` — for each dashboard in the event: (1) check if OTHER active events still affect this dashboard; (2) if yes → call `rebuild_banner()` (remove this event's info from text); (3) if no → delete chart via SupersetDashboardsWriteMixin.delete_chart(), update MaintenanceDashboardState to REMOVED; (4) transition MaintenanceEvent to COMPLETED. Contract C4. (RATIONALE: conditional removal prevents banner flicker when multiple events overlap; REJECTED: unconditional removal that briefly deletes then re-creates the banner)
|
||||
- [ ] T029 [US2] Instrument `end_maintenance()` with belief runtime markers.
|
||||
- [ ] T030 [US2] Implement `end_all_maintenance()` method in `backend/src/services/maintenance_service.py` — iterates all active events, calls `end_maintenance()` for each. Contract C4.
|
||||
- [ ] T031 [US2] Implement `POST /api/maintenance/{maintenance_id}/end` and `POST /api/maintenance/end-all` route handlers in `backend/src/api/routes/maintenance/_routes.py` — dispatch tasks via TaskManager, return task_id immediately. RBAC: `Depends(require_role("operator"))`.
|
||||
- [ ] T032 [US2] Run backend verification: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_maintenance_api.py backend/tests/test_maintenance_service.py -v -k "end"`
|
||||
|
||||
**Checkpoint**: Banner removal fully functional. Multi-event conflict handling verified. Idempotent /end confirmed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 — Settings Configuration (Priority: P3)
|
||||
|
||||
**Goal**: Administrator can configure maintenance scope (published/draft/all, excluded dashboards, forced dashboards) via API and UI.
|
||||
|
||||
**Note**: Settings model is already in Phase 2 (T007). This phase adds API endpoints and UI panel.
|
||||
|
||||
### Tests for US4
|
||||
|
||||
- [ ] T033 [P] [US4] Write API test for `GET /api/maintenance/settings` and `PUT /api/maintenance/settings` in `backend/tests/test_maintenance_api.py` — test read returns defaults, test update with valid payload, test RBAC (admin only for PUT), test invalid scope rejection.
|
||||
|
||||
### Implementation for US4
|
||||
|
||||
- [ ] T034 [US4] Implement `GET /api/maintenance/settings` and `PUT /api/maintenance/settings` route handlers in `backend/src/api/routes/maintenance/_routes.py`. GET: read MaintenanceSettings row. PUT: validate and update, return updated settings. RBAC: GET = authenticated, PUT = `Depends(require_role("admin"))`.
|
||||
- [ ] T035 [P] [US4] Implement `MaintenanceApiClient` module in `frontend/src/lib/api/maintenance.js` — typed fetch wrappers: `getSettings()`, `updateSettings(payload)`, `getEvents()`, `startMaintenance(payload)`, `endMaintenance(id)`, `endAllMaintenance()`, `getDashboardBanners()`. Uses `requestApi` from existing API layer. Contract: `[DEF:MaintenanceApiClient:Module]` C2.
|
||||
- [ ] T036 [US4] Implement `MaintenanceSettingsPanel` component in `frontend/src/lib/components/MaintenanceSettingsPanel.svelte` — collapsible form with: target environment dropdown (from existing environments config), dashboard scope radio group (Published/Drafts/All), excluded dashboards multi-select (search + chip badges), forced dashboards multi-select. Save button with loading state. UX states: idle/saving/saved/error. Toast feedback on save. Contract: `[DEF:MaintenanceSettingsPanel:Component]` C3.
|
||||
- [ ] T037 [US4] Run verification: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_maintenance_api.py -v -k "settings"` + `cd frontend && npx vitest run tests/maintenance.test.ts -v -k "settings"`
|
||||
|
||||
**Checkpoint**: Settings API operational. Settings panel renders in UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 3 — Admin UI Management Page (Priority: P2)
|
||||
|
||||
**Goal**: Administrator sees active/completed events table with selective and bulk removal actions.
|
||||
|
||||
**Independent Test**: Navigate to /maintenance, view active events, click "Remove All" → banners disappear.
|
||||
|
||||
### Tests for US3
|
||||
|
||||
- [ ] T038 [P] [US3] Write component test for `MaintenanceEventsTable` in `frontend/tests/maintenance.test.ts` — test renders active and completed events, test "Remove Banners" per event, test "Remove All" with confirmation dialog, test error state with retry.
|
||||
- [ ] T039 [P] [US3] Write store test for `MaintenanceStore` in `frontend/tests/maintenance-store.test.ts` — test events fetching, test polling, test error handling.
|
||||
|
||||
### Implementation for US3
|
||||
|
||||
- [ ] T040 [US3] Implement `GET /api/maintenance/events` route handler in `backend/src/api/routes/maintenance/_routes.py` — returns MaintenanceEventListResponse with active and completed events, each including affected dashboard count. RBAC: authenticated.
|
||||
- [ ] T041 [US3] Implement `MaintenanceStore` in `frontend/src/lib/stores/maintenance.svelte.js` — Svelte 5 rune store with `$state` for events (active + completed), settings, dashboardBanners, isLoading, error. Uses `$effect(() => subscribe(...))` pattern per ADR-0007 (REJECTED: fromStore + $derived). Auto-polls `getEvents()` every 30s via `$effect` (WebSocket covers task progress; polling ensures event-list consistency after task completion). Contract: `[DEF:MaintenanceStore:Module]` C3.
|
||||
- [ ] T042 [US3] Implement `MaintenanceEventsTable` component in `frontend/src/lib/components/MaintenanceEventsTable.svelte` — renders table with columns: Event ID, Tables (truncated with tooltip), Affected Dashboards, Start/End Times, Status badge, Actions. "Remove Banners" button per active event. "Remove All Banners" button above table with confirmation dialog. Completed events in collapsible section. UX states: idle/removing/error. Toast feedback. Contract: `[DEF:MaintenanceEventsTable:Component]` C3.
|
||||
- [ ] T043 [US3] Implement `MaintenanceBannerPage` in `frontend/src/routes/maintenance/+page.svelte` — composes MaintenanceSettingsPanel + MaintenanceEventsTable, orchestrates loading states, passes store data to children. UX states: idle/loading/action_in_progress/error/empty. Contract: `[DEF:MaintenanceBannerPage:Component]` C3.
|
||||
- [ ] T044 [US3] Add navigation entry for `/maintenance` in sidebar — add category to "Operations" section in `frontend/src/lib/components/layout/sidebarNavigation.js` with id="maintenance", path="/maintenance", requiredPermission="plugin:migration", requiredAction="WRITE". Add translation keys to `frontend/src/lib/i18n/locales/en/nav.json` and `frontend/src/lib/i18n/locales/ru/nav.json`.
|
||||
- [ ] T045 [US3] Run full frontend verification: `cd frontend && npx vitest run tests/maintenance.test.ts tests/maintenance-store.test.ts -v` + `cd frontend && npm run build`
|
||||
|
||||
**Checkpoint**: Admin management page fully functional. Settings + events tables operational.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — Dashboard Hub Badge (Priority: P3)
|
||||
|
||||
**Goal**: Dashboard Hub table shows orange "⚠️ Maintenance" badge when dashboard has active banner.
|
||||
|
||||
**Independent Test**: After US1, open Dashboard Hub → affected dashboard row shows orange badge with tooltip.
|
||||
|
||||
### Tests for US5
|
||||
|
||||
- [ ] T046 [P] [US5] Write component test for `DashboardMaintenanceBadge` in `frontend/tests/maintenance.test.ts` — test renders badge when maintenance active, test no badge when inactive, test tooltip content, test loading skeleton state.
|
||||
|
||||
### Implementation for US5
|
||||
|
||||
- [ ] T047 [US5] Implement `GET /api/maintenance/dashboard-banners` route handler in `backend/src/api/routes/maintenance/_routes.py` — returns list of MaintenanceDashboardBannerState: {dashboard_id, active: bool, events: [...], start_time, end_time}. Queries MaintenanceDashboardState where status=ACTIVE. RBAC: authenticated.
|
||||
- [ ] T048 [US5] Add `getDashboardBanners()` to MaintenanceStore — fetches banner states and populates `dashboardBanners` $state map.
|
||||
- [ ] T049 [US5] Implement `DashboardMaintenanceBadge` component in `frontend/src/lib/components/DashboardMaintenanceBadge.svelte` — orange badge "⚠️ Maintenance" with tooltip showing start/end times. Renders nothing when no active banner. Grey skeleton when loading. Contract: `[DEF:DashboardGridBannerIndicator:Component]` C2.
|
||||
- [ ] T050 [US5] Modify `frontend/src/routes/dashboards/+page.svelte` — add `DashboardMaintenanceBadge` import, insert badge column/cell in dashboard table rows, wire `$derived` from MaintenanceStore.dashboardBanners. Ensure compliance with ADR-0007 (no fromStore + $derived pattern). Export visual verification: open Dashboard Hub page after US1 and confirm badge appears via `chrome-devtools` snapshot.
|
||||
- [ ] T051 [US5] Run verification: `cd frontend && npx vitest run tests/maintenance.test.ts -v -k "badge"` + `cd frontend && npm run build`
|
||||
|
||||
**Checkpoint**: Dashboard Hub badge functional. Operators can identify maintenance-affected dashboards at a glance.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Verification
|
||||
|
||||
**Purpose**: Full integration verification, linting, semantic audit, ADR compliance check.
|
||||
|
||||
- [ ] T052 [P] Run full backend test suite: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_maintenance_*.py backend/tests/test_sql_table_extractor.py -v --cov=src/services/maintenance_service.py --cov=src/services/sql_table_extractor.py --cov=src/api/routes/maintenance/ --cov-report=term-missing`
|
||||
- [ ] T053 [P] Run backend lint: `cd backend && python -m ruff check src/api/routes/maintenance/ src/services/maintenance_service.py src/services/sql_table_extractor.py src/models/maintenance.py src/core/superset_client/_dashboards_write.py`
|
||||
- [ ] T054 [P] Run frontend lint: `cd frontend && npm run lint`
|
||||
- [ ] T055 [P] Run frontend full test suite: `cd frontend && npm run test`
|
||||
- [ ] T056 [P] Run frontend build check: `cd frontend && npm run build`
|
||||
- [ ] T057 Run semantic contract audit: `axiom_semantic_validation audit_contracts` on `backend/src/api/routes/maintenance/`, `backend/src/services/maintenance_service.py`, `backend/src/services/sql_table_extractor.py`, `backend/src/core/superset_client/_dashboards_write.py`, `backend/src/models/maintenance.py` — verify no missing C4 tags, no orphan relations, all `@RATIONALE`/`@REJECTED` present.
|
||||
- [ ] T058 Run ADR rejected-path regression check: verify that `fromStore + $derived` is NOT used in any new frontend file (ADR-0007), verify all Svelte components use runes syntax (ADR-0006), verify no export-import cycle is used for chart placement (research.md R1 rejected).
|
||||
- [ ] T059 Run belief runtime audit: `axiom_semantic_validation audit_belief_runtime` on C4 contracts — verify `belief_scope`, `log(REASON)`, `log(REFLECT)`, `log(EXPLORE)` instrumentation present in `start_maintenance()`, `end_maintenance()`, and `SupersetDashboardsWriteMixin`.
|
||||
- [ ] T060 Run quickstart.md validation: follow all steps in `specs/031-maintenance-banner/quickstart.md` (backend/frontend/Docker) and confirm no failures.
|
||||
- [ ] T061 [P] Update `frontend/src/lib/i18n/` with new translation keys for maintenance banner UI strings (English + Russian placeholders): `maintenance.title`, `maintenance.active`, `maintenance.completed`, `maintenance.removeBanners`, `maintenance.removeAll`, `maintenance.settings`, `maintenance.scope`, `maintenance.excluded`, `maintenance.forced`, `maintenance.save`, `maintenance.noActiveEvents`, `maintenance.tooltip`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
```
|
||||
Phase 1: Setup ──────────────────────────────────────────────┐
|
||||
Phase 2: Foundational (BLOCKS ALL STORIES) ──────────────────┤
|
||||
├── Phase 3: US1 (P1) Banner Placement ──────────────────┤
|
||||
│ └── Phase 4: US2 (P1) Banner Removal ────────────────┤
|
||||
├── Phase 5: US4 (P3) Settings API + Panel ──────────────┤
|
||||
│ └── Phase 6: US3 (P2) Admin UI ──────────────────────┤
|
||||
└── Phase 7: US5 (P3) Dashboard Badge ───────────────────┤
|
||||
Phase 8: Polish & Cross-Cutting ─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- **Tests MUST be written and FAIL before implementation** (T016-T018 before T019+, T025-T026 before T027+, etc.)
|
||||
- Models (T005-T007) → Schemas (T008) → SupersetClient (T009-T010) → SQL Extractor (T011) → Router (T012-T014)
|
||||
- In US1: `find_affected_dashboards()` → `build_banner_text()` → `start_maintenance()` → route handler
|
||||
- In US2: `rebuild_banner()` → `end_maintenance()` → `end_all_maintenance()` → route handlers
|
||||
- In US4: Settings API backend → API client → Settings panel
|
||||
- In US3: Events API backend → Store → EventsTable → Page
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 1**: T002 and T003 can run in parallel
|
||||
- **Phase 2**: T005, T006, T007 (models) can run in parallel; T016, T017, T018 (US1 tests) can run in parallel
|
||||
- **Phase 3**: T016, T017, T018 (tests) can run in parallel
|
||||
- **Phase 4**: T025, T026, T026a (tests) can run in parallel
|
||||
- **Phase 6**: T038, T039 (tests) can run in parallel
|
||||
- **Phase 8**: T052, T053, T054, T055, T056, T061 can run in parallel
|
||||
|
||||
### User Story Independence
|
||||
|
||||
- **US1** can be verified independently after Phase 2+3: call `/start`, check task status, verify banner in Superset
|
||||
- **US2** can be verified independently after Phase 2+4: call `/end`, verify banner removal
|
||||
- **US4** can be verified independently after Phase 2+5: GET/PUT /settings
|
||||
- **US3** can be verified independently after Phase 2+5+6: view management page, click "Remove All"
|
||||
- **US5** can be verified independently after Phase 2+7: open Dashboard Hub, see badge
|
||||
|
||||
Each story is an independently shippable increment. P1 stories (US1+US2) form the MVP.
|
||||
|
||||
---
|
||||
|
||||
## ADR & Rejected-Path Guardrails
|
||||
|
||||
| ADR / Decision | Guardrail Task | Rationale |
|
||||
|----------------|---------------|-----------|
|
||||
| ADR-0001 (Module Layout) | T001, T005-T007, T019-T023 | All files in canonical paths |
|
||||
| ADR-0003 (Orchestrator) | T009 | Superset REST API only, no DB manipulation |
|
||||
| ADR-0005 (RBAC) | T013, T023, T031, T034 | `require_role("operator"/"admin")` on all endpoints |
|
||||
| ADR-0006 (Svelte 5 Runes) | T041, T049 | `$state`, `$derived`, `$effect` only; no legacy `$:` or `writable` |
|
||||
| ADR-0007 (fromStore REJECTED) | T041 | Store uses `$effect(() => subscribe(...))`; task T058 verifies |
|
||||
| Research R1 (Direct API) | T009 | `POST /chart/` not export-import; task T058 verifies |
|
||||
| Research R3 (Global Extraction) | T011 | No Jinja pre-stripping; two-phase extraction |
|
||||
| Research R4 (TaskManager) | T023, T031 | `TaskManager.create_task()` for async execution |
|
||||
| Research R5 (Dedicated Tables) | T005-T007 | Not AppConfigRecord JSON |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Task IDs T001-T061 are sequential. 62 total tasks.
|
||||
- Tasks marked [P] can run in parallel within their phase.
|
||||
- Backend tests use `pytest -v`; frontend tests use `vitest run -v`.
|
||||
- All C4 contracts (T009-T010, T021-T022, T028-T029) MUST include belief runtime instrumentation.
|
||||
- Rejected-path regression is verified by T058 (ADR audit) and T059 (belief runtime audit).
|
||||
- `quickstart.md` validation (T060) is the final gate before merge.
|
||||
275
specs/031-maintenance-banner/ux_reference.md
Normal file
275
specs/031-maintenance-banner/ux_reference.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# UX Reference: Maintenance Banner for Dashboards
|
||||
|
||||
**Feature Branch**: `031-maintenance-banner`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Draft
|
||||
|
||||
## 1. User Persona & Context
|
||||
|
||||
* **Who is the user?**:
|
||||
- **ETL Operator**: System administrator or DevOps engineer running ETL pipelines (Airflow, cron, dbt). Does not interact with the ss-tools UI directly — API only.
|
||||
- **ss-tools Administrator**: Platform administrator managing maintenance mode settings through the web UI.
|
||||
- **Dashboard Viewer**: Business user viewing dashboards in Superset. Sees the maintenance banner but does not trigger it.
|
||||
|
||||
* **What is their goal?**:
|
||||
- Operator: Automatically warn dashboard users that data is being refreshed and may be inaccurate.
|
||||
- Administrator: Control the maintenance process, manually remove banners if needed, configure exclusions.
|
||||
- Dashboard viewer: Understand why dashboard data appears incomplete.
|
||||
|
||||
* **Context**:
|
||||
- Operator calls REST API from a pipeline (Airflow DAG, shell script, CI/CD).
|
||||
- Administrator works in the ss-tools web interface (SvelteKit SPA, desktop).
|
||||
- Dashboard viewer sees the result in Superset (third-party web application).
|
||||
|
||||
## 2. The "Happy Path" Narrative
|
||||
|
||||
**Maintenance start (Operator → ss-tools → Superset)**
|
||||
An Airflow DAG finishes clearing staging tables and triggers: `curl -X POST https://ss-tools/api/maintenance/start -d '{"tables":["raw.sales","raw.inventory"],"start_time":"2026-05-21T22:00:00+03:00","end_time":"2026-05-22T02:00:00+03:00"}'`. The API immediately returns `{"task_id":"task-abc","maintenance_id":"m-abc123","status":"pending"}`. The operator polls `GET /api/tasks/task-abc` (or receives a WebSocket notification). Within 60 seconds, the task completes, and 12 dashboards in Superset display an orange banner at the very top: "⚠️ Maintenance in progress: 2026-05-21 22:00 — 2026-05-22 02:00 MSK. Data may be incomplete." Dashboard viewers see the warning immediately upon opening the page.
|
||||
|
||||
**Maintenance end (Operator → ss-tools → Superset)**
|
||||
The ETL pipeline completes successfully. The Airflow DAG calls `curl -X POST https://ss-tools/api/maintenance/m-abc123/end`. The API returns `{"task_id":"task-end-abc","status":"pending"}`. Upon task completion, banners disappear from all dashboards. In the ss-tools UI, the administrator sees the event marked as "Completed".
|
||||
|
||||
**Manual override (Administrator → ss-tools UI → Superset)**
|
||||
An administrator notices the ETL has stalled and banners remain past the scheduled end time. They navigate to ss-tools → Maintenance → see the list of active events → click "Remove All". Within 10 seconds, all banners are removed.
|
||||
|
||||
## 3. Interface Mockups
|
||||
|
||||
### API Interaction
|
||||
|
||||
```bash
|
||||
# ===== Start maintenance =====
|
||||
$ curl -X POST https://ss-tools/api/maintenance/start \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tables": ["raw.sales", "raw.inventory"],
|
||||
"start_time": "2026-05-21T22:00:00+03:00",
|
||||
"end_time": "2026-05-22T02:00:00+03:00",
|
||||
"message": "Scheduled data mart refresh"
|
||||
}'
|
||||
|
||||
# Success response — async (HTTP 202):
|
||||
{
|
||||
"task_id": "task-maint-abc123",
|
||||
"maintenance_id": "m-ev-20260521-abc123",
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
# Poll task status:
|
||||
$ curl https://ss-tools/api/tasks/task-maint-abc123 \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
|
||||
# Task completed response:
|
||||
{
|
||||
"task_id": "task-maint-abc123",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"maintenance_id": "m-ev-20260521-abc123",
|
||||
"affected_dashboards": 12,
|
||||
"dashboards": [
|
||||
{"id": 42, "title": "Daily Sales Overview", "chart_id": 902},
|
||||
{"id": 57, "title": "Inventory Health Check", "chart_id": 903}
|
||||
],
|
||||
"unmatched_tables": []
|
||||
}
|
||||
}
|
||||
|
||||
# Partial success — async (HTTP 202):
|
||||
{
|
||||
"task_id": "task-maint-def456",
|
||||
"maintenance_id": "m-ev-20260521-def456",
|
||||
"status": "pending"
|
||||
}
|
||||
# Task result on completion (via GET /api/tasks/{task_id}):
|
||||
{
|
||||
"task_id": "task-maint-def456",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"maintenance_id": "m-ev-20260521-def456",
|
||||
"status": "partial",
|
||||
"affected_dashboards": 10,
|
||||
"failed_dashboards": [
|
||||
{"id": 99, "title": "Archived Report", "error": "Dashboard not found in Superset"}
|
||||
],
|
||||
"unmatched_tables": ["raw.unknown_table"]
|
||||
}
|
||||
}
|
||||
|
||||
# No dashboards found — sync fast-path (HTTP 200, no task needed):
|
||||
{
|
||||
"maintenance_id": null,
|
||||
"status": "no_match",
|
||||
"affected_dashboards": 0,
|
||||
"message": "No dashboards found referencing tables: raw.archived",
|
||||
"unmatched_tables": ["raw.archived"]
|
||||
}
|
||||
|
||||
# ===== End maintenance =====
|
||||
$ curl -X POST https://ss-tools/api/maintenance/m-ev-20260521-abc123/end \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
|
||||
# Success response — async (HTTP 202):
|
||||
{
|
||||
"task_id": "task-end-abc123",
|
||||
"status": "pending"
|
||||
}
|
||||
# Task result on completion:
|
||||
{
|
||||
"task_id": "task-end-abc123",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"maintenance_id": "m-ev-20260521-abc123",
|
||||
"removed_from": 12,
|
||||
"failed_removals": []
|
||||
}
|
||||
}
|
||||
|
||||
# Already completed (HTTP 200 — sync, no task needed):
|
||||
{
|
||||
"maintenance_id": "m-ev-20260521-abc123",
|
||||
"status": "already_completed",
|
||||
"message": "Maintenance was already completed at 2026-05-22T02:05:00+03:00"
|
||||
}
|
||||
|
||||
# ===== Bulk removal =====
|
||||
$ curl -X POST https://ss-tools/api/maintenance/end-all \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
|
||||
# Success response — async (HTTP 202):
|
||||
{
|
||||
"task_id": "task-endall-xyz",
|
||||
"status": "pending"
|
||||
}
|
||||
# Task result on completion:
|
||||
{
|
||||
"task_id": "task-endall-xyz",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"closed_events": 3,
|
||||
"removed_from": 25,
|
||||
"failed_removals": []
|
||||
}
|
||||
}
|
||||
|
||||
# ===== Settings =====
|
||||
$ curl https://ss-tools/api/maintenance/settings \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
|
||||
# Response:
|
||||
{
|
||||
"target_environment_id": "prod",
|
||||
"dashboard_scope": "published_only",
|
||||
"excluded_dashboard_ids": [99, 101],
|
||||
"forced_dashboard_ids": [10, 42],
|
||||
"updated_at": "2026-05-20T15:00:00+03:00"
|
||||
}
|
||||
|
||||
$ curl -X PUT https://ss-tools/api/maintenance/settings \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"dashboard_scope": "all", "excluded_dashboard_ids": [99]}'
|
||||
|
||||
# Response: (settings object, same shape as GET)
|
||||
```
|
||||
|
||||
### UI Layout & Flow
|
||||
|
||||
**Screen 1: Maintenance Banners Management** (`/maintenance`)
|
||||
|
||||
* **Layout**: Single-page management panel. Top section — settings, bottom section — events table.
|
||||
* **Key Elements**:
|
||||
* **Settings Panel (collapsible)**:
|
||||
* `Dashboard Scope`: Radio group: "Published only" / "Drafts only" / "All".
|
||||
* `Excluded Dashboards`: Multi-select search by dashboard title. Removable badge chips.
|
||||
* `Forced Dashboards`: Multi-select search by dashboard title. Removable badge chips.
|
||||
* "Save Settings" button (Primary, blue).
|
||||
* **Active Events Table**:
|
||||
* Columns: Event ID, Tables (truncated list + tooltip), Affected Dashboards, Start Time, End Time, Status (badge: 🟢 Active / ✅ Completed / ❌ Failed), Actions.
|
||||
* Actions: "Remove Banners" button (Warning, orange) per active event.
|
||||
* Above table: "Remove All Banners" button (Danger, red) with confirmation dialog.
|
||||
* **Completed Events Table** (collapsible, hidden by default):
|
||||
* Same columns + "Completed At" timestamp. No action buttons.
|
||||
* **Contract Mapping**:
|
||||
* **`@UX_STATE`**: `idle` (table loaded), `loading` (skeleton), `action_in_progress` (remove button shows spinner, others disabled), `error` (error toast, retry available), `empty` (no active events — placeholder "No active maintenance events").
|
||||
* **`@UX_FEEDBACK`**: Toast notifications: success (green "Banners removed from N dashboards"), error (red "Removal error: ..."), warning (yellow "Partial success: X/Y processed").
|
||||
* **`@UX_RECOVERY`**: On removal error — "Retry" button per failed dashboard. On network error — auto-retry after 3s with countdown display.
|
||||
* **`@UX_REACTIVITY`**: `$state` for activeEvents, completedEvents, settings; `$derived` for filtered/sorted views; `$effect` for auto-refresh (30s polling).
|
||||
|
||||
**Screen 2: Dashboard Hub — Maintenance Indicator** (modification to existing `/dashboards`)
|
||||
|
||||
* **Layout**: New indicator added to each dashboard row (alongside existing Git/LLM status badges).
|
||||
* **Key Elements**:
|
||||
* **Maintenance Badge**: Orange badge "⚠️ Maintenance" in the dashboard row.
|
||||
* **Tooltip**: On hover — start and end times, source event ID.
|
||||
* **Contract Mapping**:
|
||||
* **`@UX_STATE`**: `no_maintenance` (no badge), `maintenance_active` (orange badge), `loading` (grey skeleton badge).
|
||||
* **`@UX_REACTIVITY`**: `$derived` from maintenance state data, refreshed alongside dashboard polling.
|
||||
|
||||
**Screen 3: Superset Dashboard — Maintenance Markdown Chart** (result in third-party application)
|
||||
|
||||
* **Layout**: Markdown chart at the very top of the dashboard (position (0,0) in layout), full width.
|
||||
* **Appearance**:
|
||||
```
|
||||
⚠️ Maintenance in progress
|
||||
|
||||
Scheduled data mart refresh
|
||||
Start: May 21, 2026, 10:00 PM (MSK)
|
||||
End: May 22, 2026, 2:00 AM (MSK)
|
||||
|
||||
Data may be incomplete or temporarily unavailable.
|
||||
```
|
||||
* **Style**: Markdown with orange-tinted background (CSS via markdown HTML or Superset markdown CSS class). Bold header.
|
||||
|
||||
## 4. The "Error" Experience
|
||||
|
||||
**Philosophy**: API returns maximally detailed responses so the caller can make informed decisions. UI provides explicit recovery paths.
|
||||
|
||||
### Scenario A: Table not found in any dashboard
|
||||
|
||||
* **User Action (Operator)**: Passes a table name with a typo: `raw.sales_typo`.
|
||||
* **System Response**: `{"status": "no_match", "affected_dashboards": 0, "message": "No dashboards found referencing tables: raw.sales_typo"}`.
|
||||
* **Recovery**: Operator corrects the table name and retries.
|
||||
|
||||
### Scenario B: Some dashboards no longer exist in Superset
|
||||
|
||||
* **User Action (Operator)**: Initiates maintenance, but 3 of 15 dashboards have been deleted from Superset.
|
||||
* **System Response**: HTTP 207 Multi-Status with `failed_dashboards` field containing id and reason for each problematic dashboard. Remaining 12 dashboards successfully receive banners.
|
||||
* **Recovery**: Operator reviews failed_dashboards and adjusts the exclusion list if needed.
|
||||
|
||||
### Scenario C: Superset API network timeout
|
||||
|
||||
* **User Action (Operator)**: Initiates maintenance, but Superset responds slowly.
|
||||
* **System Response**: Timeout after 30 seconds. Returns HTTP 207 with `status: "partial"`, listing processed and unprocessed dashboards.
|
||||
* **Recovery**: Operator can re-call `/maintenance/start` with the same parameters — the system only processes dashboards that don't yet have an active banner.
|
||||
|
||||
### Scenario D: Multiple maintenance events on one dashboard
|
||||
|
||||
* **User Action**: Two ETL processes simultaneously update tables affecting dashboard D.
|
||||
* **System Response**: Dashboard D shows a single markdown chart with aggregated text: "⚠️ Multiple data refresh processes in progress. ..."
|
||||
* **Recovery**: The banner is only removed when ALL maintenance events for D are completed. Premature `/end` for one event updates the banner text but keeps it visible.
|
||||
|
||||
### Scenario E: Insufficient permissions
|
||||
|
||||
* **User Action**: API call without `operator` or `admin` role.
|
||||
* **System Response**: HTTP 403 `{"detail": "Insufficient permissions. Required role: operator or admin"}`.
|
||||
* **Recovery**: Request role assignment from administrator.
|
||||
|
||||
## 5. Tone & Voice
|
||||
|
||||
* **Style**: Technical, concise, informative. No alarmism, but clear indication of possible data issues.
|
||||
* **Terminology**:
|
||||
- "Maintenance" (not "downtime" or "outage" — data is still accessible, just potentially stale)
|
||||
- "Dashboard" (not "report" or "view")
|
||||
- "Banner" (used in API/UI/internal docs for the markdown chart)
|
||||
- "Dataset" (not "datasource")
|
||||
- "Table" (not "source" or "relation")
|
||||
* **Default banner template**:
|
||||
```
|
||||
⚠️ Maintenance in progress
|
||||
{message}
|
||||
Start: {start_time_formatted}
|
||||
End: {end_time_formatted}
|
||||
Data may be incomplete or temporarily unavailable.
|
||||
```
|
||||
If `message` is not provided, the first body line is omitted.
|
||||
* **Dashboard Hub tooltip**: "Maintenance active: {start} — {end}"
|
||||
Reference in New Issue
Block a user