fix(maintenance): render banner as native MARKDOWN element, not a chart

Two defects fixed:
- ensure_banner_chart created an orphan 'Maintenance Banner' markdown chart
  (polluted Charts menu and dashboard exports). The banner is now a native
  MARKDOWN element in position_json; chart_id is a synthetic layout key.
- insert_banner_markdown_at_top blindly targeted GRID_ID; on ROOT->TABS
  dashboards (FI-0085) GRID_ID is orphaned and the banner never rendered.
  The layout is now normalized to ROOT->GRID_ID->[ROW-banner, ...] with
  recursive parents update, matching the proven-working manual example.

Review-driven hardening:
- liveness check verifies reachability from ROOT (children graph), so
  dashboards corrupted by the old bug self-heal on the next start.
- insert removes stale ROW-banner-*/MARKDOWN-banner-* keys (single banner).
- decision memory (@RATIONALE/@REJECTED) added to both modules.
- new ops script scripts/cleanup_maintenance_banner_charts.py (dry-run by
  default) deletes already-created bogus banner charts in prod.
This commit is contained in:
2026-08-04 08:46:22 +07:00
parent 4d282b43e2
commit 80dad15458
7 changed files with 702 additions and 205 deletions

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Cleanup of orphaned "Maintenance Banner" charts in Superset.
Context (ADR: maintenance banner is a native MARKDOWN element, not a chart):
Before the fix, maintenance:start created a real markdown CHART resource
(slice_name="Maintenance Banner", viz_type="markdown") that was never rendered
— the visible banner is a native MARKDOWN element in the dashboard position_json.
The fix stops creating these charts, but the ones already created remain in the
Charts menu and in dashboard exports. This script finds and deletes them.
Safety:
- DRY-RUN by default. Pass --yes to actually delete.
- Matches charts by slice_name == "Maintenance Banner" AND viz_type == "markdown"
AND params containing the known placeholder text, so legitimate charts are
never matched.
- Re-checks every candidate via GET /chart/{id} before deletion.
- The dashboard layouts are NOT touched: banners live as native MARKDOWN elements
in position_json and are unaffected by chart deletion. Stale maintenance_banners
DB rows self-heal on the next maintenance:start (reachability liveness check).
Usage:
python scripts/cleanup_maintenance_banner_charts.py [--yes] \
[--url https://superset.example.com] [--username admin] [--password ...]
# or via env: SUPERSET_URL / SUPERSET_USERNAME / SUPERSET_PASSWORD / SUPERSET_VERIFY_SSL
Output: per-chart decision (DELETE/SKIP) + JSON summary on stdout.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
from src.core.config_models import Environment # noqa: E402
from src.core.superset_client import SupersetClient # noqa: E402
BANNER_SLICE_NAME = "Maintenance Banner"
BANNER_PLACEHOLDER = "Maintenance banner placeholder"
GREEN = "\033[32m"
RED = "\033[31m"
YELLOW = "\033[33m"
RESET = "\033[0m"
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=os.environ.get("SUPERSET_URL", ""))
parser.add_argument("--username", default=os.environ.get("SUPERSET_USERNAME", ""))
parser.add_argument("--password", default=os.environ.get("SUPERSET_PASSWORD", ""))
parser.add_argument(
"--verify-ssl",
action="store_true",
default=os.environ.get("SUPERSET_VERIFY_SSL", "1") not in ("0", "false", "False"),
)
parser.add_argument(
"--yes",
action="store_true",
help="Actually delete matching charts. Without it the script only reports.",
)
return parser.parse_args()
def _looks_like_bogus_banner(chart: dict) -> bool:
"""A candidate chart is bogus only if it is a markdown chart with the banner name.
The placeholder check happens later on the full chart payload (GET /chart/{id});
here we only pre-filter the list response columns.
"""
return (
chart.get("slice_name") == BANNER_SLICE_NAME
and chart.get("viz_type") == "markdown"
)
def _params_contain_placeholder(chart: dict) -> bool:
params = chart.get("params")
if not isinstance(params, str):
return False
try:
parsed = json.loads(params)
except json.JSONDecodeError:
return False
markdown = parsed.get("markdown", "") if isinstance(parsed, dict) else ""
return BANNER_PLACEHOLDER in str(markdown)
async def _find_candidates(client: SupersetClient) -> list[dict]:
"""Fetch all charts named 'Maintenance Banner' (paginated)."""
candidates: list[dict] = []
page = 0
page_size = 100
while True:
query = {
"columns": ["id", "slice_name", "viz_type"],
"filters": [{"col": "slice_name", "opr": "eq", "value": BANNER_SLICE_NAME}],
"page_size": page_size,
"page": page,
}
count, charts = await client.get_charts(query)
for chart in charts:
if _looks_like_bogus_banner(chart):
candidates.append(chart)
if page * page_size + len(charts) >= count or not charts:
break
page += 1
return candidates
async def _delete_chart(client: SupersetClient, chart_id: int) -> None:
await client.request(method="DELETE", endpoint=f"/chart/{chart_id}")
async def run(args: argparse.Namespace, client: SupersetClient | None = None) -> int:
if client is None:
missing = [
name
for name, val in (("url", args.url), ("username", args.username), ("password", args.password))
if not val
]
if missing:
print(f"{RED}Missing required config: {', '.join(missing)} (args or env){RESET}", file=sys.stderr)
return 2
env = Environment(
id="cleanup",
name="Cleanup",
url=args.url,
username=args.username,
password=args.password,
stage="PROD",
verify_ssl=args.verify_ssl,
)
client = SupersetClient(env)
print(f"{YELLOW}Scanning {args.url} for '{BANNER_SLICE_NAME}' markdown charts...{RESET}")
candidates = await _find_candidates(client)
if not candidates:
print(f"{GREEN}No 'Maintenance Banner' markdown charts found — nothing to do.{RESET}")
return 0
print(f"Found {len(candidates)} candidate chart(s). Verifying each via GET /chart/{{id}}...")
decisions: dict[str, list[dict]] = {"DELETE": [], "SKIP": []}
for cand in candidates:
chart_id = int(cand["id"])
try:
full = await client.get_chart(chart_id)
except Exception as exc: # noqa: BLE001 - report and continue
decisions["SKIP"].append({"id": chart_id, "reason": f"get_chart failed: {exc}"})
print(f" {YELLOW}SKIP {chart_id}: cannot fetch full payload ({exc}){RESET}")
continue
if not _params_contain_placeholder(full):
decisions["SKIP"].append(
{"id": chart_id, "reason": "params do not contain the banner placeholder — not our chart"}
)
print(f" {YELLOW}SKIP {chart_id}: params lack placeholder — not a bogus banner chart{RESET}")
continue
decisions["DELETE"].append({"id": chart_id})
print(f" {RED}DELETE {chart_id}{RESET} ({BANNER_SLICE_NAME}, markdown, placeholder present)")
if not args.yes:
print(
f"\n{YELLOW}DRY-RUN: {len(decisions['DELETE'])} chart(s) would be deleted. "
f"Re-run with --yes to apply.{RESET}"
)
else:
for item in decisions["DELETE"]:
try:
await _delete_chart(client, item["id"])
print(f" {GREEN}deleted {item['id']}{RESET}")
except Exception as exc: # noqa: BLE001 - report and continue
item["error"] = str(exc)
print(f" {RED}FAILED to delete {item['id']}: {exc}{RESET}")
summary = {
"url": args.url,
"dry_run": not args.yes,
"candidates_found": len(candidates),
"decisions": decisions,
}
print(f"\n{json.dumps(summary, ensure_ascii=False, indent=2)}")
return 0
def main() -> int:
args = _parse_args()
try:
return asyncio.run(run(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())