Spec: 5 user stories (P1-P3), 17 FRs, 7 SCs, 12 edge cases, RBAC matrix Plan: 7 research decisions, 15 semantic contracts (C1-C4) Data: 4 SQLAlchemy models (Banner entity enforces one-chart-per-dashboard) UX: async-only API, banner template with optional variables, admin settings UI Tasks: 64 tasks across 8 phases, 12 reused frontend components Workflow: component reuse scan mandated in speckit.plan/tasks + semantics-svelte Constitution: filled with 7 architecture principles from ADRs
344 lines
14 KiB
Markdown
344 lines
14 KiB
Markdown
# 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) │
|
|
│ display_timezone │ │ tables: JSON (str[]) │
|
|
│ dashboard_scope │ │ start_time: datetime │
|
|
│ excluded_ids: JSON │ │ end_time: datetime │
|
|
│ forced_ids: JSON │ │ message: str? │
|
|
│ updated_at │ │ status: enum(pending|applying │
|
|
└──────────────────────┘ │ |active|partial|ending │
|
|
│ |completed|failed) │
|
|
│ created_at / updated_at │
|
|
└──────────┬────────────────────┘
|
|
│ 1:N
|
|
▼
|
|
┌─────────────────────────────┐
|
|
│ MaintenanceDashboardState │
|
|
│ │
|
|
│ id: str (PK) │
|
|
│ event_id: str (FK→Event) │
|
|
│ dashboard_id: int │
|
|
│ banner_id: str (FK→Banner) │
|
|
│ status: enum(pending_apply │
|
|
│ |active|removing │
|
|
│ |removed|apply_failed │
|
|
│ |removal_failed) │
|
|
│ created_at / updated_at │
|
|
└──────────┬──────────────────┘
|
|
│ N:1
|
|
▼
|
|
┌─────────────────────────────┐
|
|
│ MaintenanceDashboardBanner │
|
|
│ (ONE chart per dashboard) │
|
|
│ │
|
|
│ id: str (PK) │
|
|
│ environment_id: str │
|
|
│ dashboard_id: int │
|
|
│ chart_id: int? (Superset) │
|
|
│ banner_text: text │
|
|
│ status: enum(active|removed)│
|
|
│ UNIQUE (env_id, dash_id, │
|
|
│ status=active) │
|
|
│ created_at / updated_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):
|
|
PENDING = "pending"
|
|
APPLYING = "applying"
|
|
ACTIVE = "active"
|
|
PARTIAL = "partial" # some dashboards succeeded, some failed
|
|
ENDING = "ending"
|
|
COMPLETED = "completed"
|
|
FAILED = "failed"
|
|
|
|
class MaintenanceEvent(Base):
|
|
__tablename__ = "maintenance_events"
|
|
|
|
id = Column(String, primary_key=True, index=True)
|
|
task_id = Column(String, nullable=True, index=True) # Loose reference — no FK to avoid circular dependency
|
|
environment_id = Column(String, nullable=False) # Target env at event creation (snapshot)
|
|
tables = Column(JSON, nullable=False)
|
|
start_time = Column(DateTime(timezone=True), nullable=False)
|
|
end_time = Column(DateTime(timezone=True), nullable=True) # nullable — "Окончание: уточняется" when absent
|
|
message = Column(Text, nullable=True)
|
|
status = Column(
|
|
Enum(MaintenanceEventStatus),
|
|
nullable=False,
|
|
default=MaintenanceEventStatus.PENDING,
|
|
index=True
|
|
)
|
|
created_at = Column(DateTime(timezone=True), server_default="now()")
|
|
updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()")
|
|
|
|
dashboard_states = relationship(
|
|
"MaintenanceDashboardState",
|
|
back_populates="maintenance_event",
|
|
cascade="all, delete-orphan"
|
|
)
|
|
```
|
|
|
|
### MaintenanceDashboardBanner
|
|
|
|
```python
|
|
class MaintenanceDashboardBannerStatus(str, enum.Enum):
|
|
ACTIVE = "active"
|
|
REMOVED = "removed"
|
|
|
|
class MaintenanceDashboardBanner(Base):
|
|
__tablename__ = "maintenance_dashboard_banners"
|
|
|
|
id = Column(String, primary_key=True, index=True)
|
|
environment_id = Column(String, nullable=False)
|
|
dashboard_id = Column(Integer, nullable=False, index=True)
|
|
chart_id = Column(Integer, nullable=True) # Superset markdown chart ID
|
|
banner_text = Column(Text, nullable=True) # Current aggregated markdown
|
|
status = Column(
|
|
Enum(MaintenanceDashboardBannerStatus),
|
|
nullable=False,
|
|
default=MaintenanceDashboardBannerStatus.ACTIVE
|
|
)
|
|
created_at = Column(DateTime(timezone=True), server_default="now()")
|
|
updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()")
|
|
|
|
dashboard_states = relationship("MaintenanceDashboardState", back_populates="banner")
|
|
|
|
__table_args__ = (
|
|
Index("ix_banner_unique_active", "environment_id", "dashboard_id",
|
|
unique=True, postgresql_where=(status == "active")),
|
|
)
|
|
```
|
|
|
|
### MaintenanceDashboardState
|
|
|
|
```python
|
|
class MaintenanceDashboardStateStatus(str, enum.Enum):
|
|
PENDING_APPLY = "pending_apply"
|
|
ACTIVE = "active"
|
|
REMOVING = "removing"
|
|
REMOVED = "removed"
|
|
APPLY_FAILED = "apply_failed"
|
|
REMOVAL_FAILED = "removal_failed"
|
|
|
|
class MaintenanceDashboardState(Base):
|
|
__tablename__ = "maintenance_dashboard_states"
|
|
|
|
id = Column(String, primary_key=True, index=True)
|
|
event_id = Column(String, ForeignKey("maintenance_events.id"), nullable=False, index=True)
|
|
dashboard_id = Column(Integer, nullable=False, index=True)
|
|
banner_id = Column(String, ForeignKey("maintenance_dashboard_banners.id"), nullable=True)
|
|
status = Column(
|
|
Enum(MaintenanceDashboardStateStatus),
|
|
nullable=False,
|
|
default=MaintenanceDashboardStateStatus.PENDING_APPLY
|
|
)
|
|
created_at = Column(DateTime(timezone=True), server_default="now()")
|
|
updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()")
|
|
|
|
maintenance_event = relationship("MaintenanceEvent", back_populates="dashboard_states")
|
|
banner = relationship("MaintenanceDashboardBanner", back_populates="dashboard_states")
|
|
|
|
__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")
|
|
target_environment_id = Column(String, nullable=False)
|
|
display_timezone = Column(String, nullable=False, default="UTC")
|
|
banner_template = Column(Text, nullable=False, default=(
|
|
'<div style="background:#FFF3E0;padding:16px;border-left:4px solid #FF9800;border-radius:4px">\n\n'
|
|
'## ⚠️ Технические работы\n\n'
|
|
'{message}\n\n'
|
|
'**Начало:** {start_time}\n'
|
|
'**Конец:** {end_time}\n\n'
|
|
'*Данные могут быть неполными или временно недоступны.*\n\n'
|
|
'</div>'
|
|
))
|
|
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()")
|
|
|
|
__table_args__ = (
|
|
CheckConstraint("id = 'default'", name="ck_settings_singleton"),
|
|
)
|
|
```
|
|
|
|
## 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)
|
|
start_time: datetime # required
|
|
end_time: datetime | None = None # optional — absent → «уточняется»
|
|
message: str | None = Field(None, max_length=500)
|
|
|
|
class MaintenanceSettingsUpdate(BaseModel):
|
|
target_environment_id: str | None = None
|
|
display_timezone: str | None = None
|
|
banner_template: str | None = None
|
|
dashboard_scope: str | None = None
|
|
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
|
|
display_timezone: str
|
|
banner_template: 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] → PENDING → APPLYING → ACTIVE (all banners placed)
|
|
└──→ PARTIAL (some banners failed)
|
|
└──→ FAILED (all banners failed)
|
|
ACTIVE ──→ ENDING → COMPLETED
|
|
PARTIAL ──→ ENDING → COMPLETED
|
|
```
|
|
|
|
### MaintenanceDashboardBanner
|
|
```
|
|
[created] → ACTIVE ──→ REMOVED
|
|
```
|
|
|
|
### MaintenanceDashboardState
|
|
```
|
|
[created] → PENDING_APPLY → ACTIVE
|
|
└──→ APPLY_FAILED
|
|
ACTIVE ──→ REMOVING → REMOVED
|
|
└──→ REMOVAL_FAILED
|
|
```
|
|
|
|
### Event Conflict Matrix
|
|
| Dashboard has | New /start | Idempotency Key | Behavior |
|
|
|--------------|-----------|-----------------|----------|
|
|
| No banner | tables match | — | Create `MaintenanceDashboardBanner` + `MaintenanceDashboardState(PENDING_APPLY→ACTIVE)` |
|
|
| Banner ACTIVE from M1 | tables + (start, end) differ | New event | Create `MaintenanceDashboardState(PENDING_APPLY)` → link to existing Banner → rebuild text |
|
|
| Banner ACTIVE from M1 | same (tables, start, end) | Duplicate | Return existing `maintenance_id`, `status:"already_active"` |
|
|
| Banner ACTIVE from M1 | same tables+start, end was None, now set | New event | Different `end_time` value → new event. Old event remains active until explicitly ended. |
|
|
| Banner REMOVED | any tables | — | M1 already completed, treat as no banner |
|