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:
203
scripts/cleanup_maintenance_banner_charts.py
Normal file
203
scripts/cleanup_maintenance_banner_charts.py
Normal 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())
|
||||
Reference in New Issue
Block a user