Coverage for scripts / capture_monitoring_screenshots.py: 100.00%
77 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Capture Grafana dashboard screenshots for the monitoring docs.
3Renders each curated GCO dashboard to the repo's ``images/`` directory using a
4headless Chromium via Playwright (the same rendering dependency the code-diagram
5generator uses). It drives a live Grafana reached through a
6``gco monitoring open`` port-forward, so regenerating the doc assets after a
7dashboard change is a two-step, on-demand flow:
9 # 1. In one shell, port-forward Grafana. The cluster's API endpoint is
10 # private, so tunnel through SSM; ``--via-ssm auto`` provisions a
11 # self-terminating ephemeral bastion and tears it down on exit (or pass
12 # an existing instance with ``--via-ssm <instance-id>``):
13 gco monitoring open --region us-east-1 --via-ssm auto
15 # 2. In another, capture the dashboards (Chromium fetched once with
16 # ``playwright install chromium``):
17 python scripts/capture_monitoring_screenshots.py \
18 --username admin --password "$GCO_GRAFANA_ADMIN_PASSWORD"
20The set of dashboards captured here is kept in lockstep with the dashboard
21ConfigMaps in
22``lambda/kubectl-applier-simple/manifests/post-helm-grafana-dashboards.yaml`` by
23``tests/test_cluster_observability_screenshots.py`` — add a dashboard there and
24the test fails until it is added to ``SCREENSHOTS`` below.
26The native OpenCost UI (not a Grafana dashboard) is captured too when
27``--opencost-url`` is passed. It rides a second port-forward::
29 # third shell: OpenCost UI on localhost:9091
30 gco monitoring open --service opencost --region us-east-1 --via-ssm auto
31 python scripts/capture_monitoring_screenshots.py \
32 --username admin --password "$GCO_GRAFANA_ADMIN_PASSWORD" \
33 --opencost-url http://localhost:9091
34"""
36from __future__ import annotations
38import argparse
39import base64
40import sys
41from dataclasses import dataclass
42from pathlib import Path
44PROJECT_ROOT = Path(__file__).resolve().parent.parent
45IMAGES_DIR = PROJECT_ROOT / "images"
46DEFAULT_GRAFANA_URL = "http://localhost:3000"
48# How long to let a dashboard's panels finish rendering before the screenshot.
49# Generous because the SPA has to initialise, fetch the dashboard, and run its
50# panel queries — each a round trip that, over an SSM tunnel, carries latency.
51_PANEL_RENDER_WAIT_MS = 4000
54@dataclass(frozen=True)
55class Screenshot:
56 """One dashboard capture: its Grafana ``uid`` and the output filename."""
58 dashboard_uid: str
59 filename: str
60 title: str
63# One entry per curated dashboard. The ``dashboard_uid`` values must match the
64# ``uid`` in each dashboard JSON under post-helm-grafana-dashboards.yaml and
65# post-helm-grafana-cost-dashboard.yaml.
66SCREENSHOTS: tuple[Screenshot, ...] = (
67 Screenshot("gco-gpu-dcgm", "grafana-gpu-dcgm.png", "GCO GPU (DCGM)"),
68 Screenshot("gco-schedulers", "grafana-schedulers.png", "GCO Schedulers & Queues"),
69 Screenshot("gco-keda", "grafana-keda.png", "GCO KEDA Autoscaling"),
70 Screenshot("gco-services", "grafana-services.png", "GCO Services"),
71 Screenshot("gco-cost", "grafana-cost.png", "GCO Cost (OpenCost)"),
72)
74# The native OpenCost UI is an SPA served by the opencost pod, not a Grafana
75# dashboard, so it sits outside the uid-keyed SCREENSHOTS lockstep. Captured
76# only when --opencost-url is passed (it needs its own port-forward).
77OPENCOST_UI_FILENAME = "opencost-ui.png"
80def expected_output_paths(images_dir: Path = IMAGES_DIR) -> list[Path]:
81 """Return the image paths this script writes, under ``images_dir``."""
82 return [images_dir / shot.filename for shot in SCREENSHOTS]
85def capture_opencost_ui(opencost_url: str, output_dir: Path) -> Path:
86 """Screenshot the native OpenCost UI. Returns the written path.
88 The UI is unauthenticated behind the port-forward, so no credentials are
89 involved — just navigate and let the allocation table render. The SPA
90 fires its allocation query on load; the fixed wait mirrors the Grafana
91 panel-render wait above.
92 """
93 from playwright.sync_api import sync_playwright
95 output_dir.mkdir(parents=True, exist_ok=True)
96 out = output_dir / OPENCOST_UI_FILENAME
97 with sync_playwright() as playwright:
98 browser = playwright.chromium.launch(headless=True)
99 try:
100 context = browser.new_context(viewport={"width": 1600, "height": 900})
101 page = context.new_page()
102 page.goto(opencost_url.rstrip("/"), wait_until="load")
103 page.wait_for_timeout(_PANEL_RENDER_WAIT_MS)
104 page.screenshot(path=str(out), full_page=True)
105 finally:
106 browser.close()
107 return out
110def capture(
111 grafana_url: str,
112 username: str,
113 password: str,
114 output_dir: Path,
115 time_from: str | None = None,
116 time_to: str | None = None,
117) -> list[Path]:
118 """Authenticate to Grafana and screenshot each dashboard. Returns written paths.
120 Authentication sends an HTTP basic-auth ``Authorization`` header on every
121 request (Grafana's ``auth.basic`` is enabled by default). The header is set
122 proactively rather than via Playwright's ``http_credentials`` because Grafana
123 redirects an unauthenticated browser navigation to ``/login`` (a 302) instead
124 of issuing a 401 challenge — so ``http_credentials``, which only answers a
125 401, would never send the header and the capture would screenshot the login
126 page. Setting the header outright mirrors ``curl -u`` and keeps the SPA
127 authenticated, so no login form is driven.
129 ``time_from`` / ``time_to`` optionally override each dashboard's saved time
130 range via the ``from``/``to`` URL params (e.g. ``now-30m`` / ``now``). The
131 curated dashboards save a 6h default; when a capture follows a short burst of
132 live load, zooming to the active window (``now-30m``) makes the panels read
133 as a full curve rather than a sliver at the right edge. When unset, the
134 dashboard's own range is used.
136 Playwright is imported lazily so this module can be imported (and its
137 metadata inspected by tests) without the browser being installed.
138 """
139 from playwright.sync_api import sync_playwright
141 output_dir.mkdir(parents=True, exist_ok=True)
142 written: list[Path] = []
143 base = grafana_url.rstrip("/")
144 token = base64.b64encode(f"{username}:{password}".encode()).decode()
145 query = "?kiosk"
146 if time_from:
147 query += f"&from={time_from}"
148 if time_to:
149 query += f"&to={time_to}"
150 with sync_playwright() as playwright:
151 browser = playwright.chromium.launch(headless=True)
152 try:
153 context = browser.new_context(
154 viewport={"width": 1600, "height": 900},
155 extra_http_headers={"Authorization": f"Basic {token}"},
156 )
157 page = context.new_page()
158 for shot in SCREENSHOTS:
159 # ``kiosk`` hides Grafana chrome so the screenshot is just panels.
160 # Wait for ``load`` (not ``networkidle``) — a live dashboard's
161 # periodic queries can keep the network busy indefinitely — then
162 # give the panels a fixed moment to finish rendering.
163 page.goto(f"{base}/d/{shot.dashboard_uid}{query}", wait_until="load")
164 page.wait_for_timeout(_PANEL_RENDER_WAIT_MS)
165 out = output_dir / shot.filename
166 page.screenshot(path=str(out), full_page=True)
167 written.append(out)
168 finally:
169 browser.close()
170 return written
173def main(argv: list[str] | None = None) -> int:
174 parser = argparse.ArgumentParser(
175 description="Capture GCO Grafana dashboard screenshots for the monitoring docs."
176 )
177 parser.add_argument("--grafana-url", default=DEFAULT_GRAFANA_URL)
178 parser.add_argument("--username", default="admin")
179 parser.add_argument("--password", required=True)
180 parser.add_argument("--output-dir", type=Path, default=IMAGES_DIR)
181 parser.add_argument(
182 "--from",
183 dest="time_from",
184 default=None,
185 help="Grafana time-range start (e.g. now-30m). Defaults to each dashboard's saved range.",
186 )
187 parser.add_argument(
188 "--to",
189 dest="time_to",
190 default=None,
191 help="Grafana time-range end (e.g. now). Defaults to each dashboard's saved range.",
192 )
193 parser.add_argument(
194 "--opencost-url",
195 default=None,
196 help=(
197 "Also capture the native OpenCost UI from this URL "
198 "(e.g. http://localhost:9091 via 'gco monitoring open --service opencost'). "
199 "Skipped when unset."
200 ),
201 )
202 args = parser.parse_args(argv)
204 try:
205 written = capture(
206 args.grafana_url,
207 args.username,
208 args.password,
209 args.output_dir,
210 time_from=args.time_from,
211 time_to=args.time_to,
212 )
213 if args.opencost_url:
214 written.append(capture_opencost_ui(args.opencost_url, args.output_dir))
215 except Exception as exc: # noqa: BLE001 — surface any Playwright/login failure
216 print(f"screenshot capture failed: {exc}", file=sys.stderr)
217 return 1
219 for path in written:
220 print(f"wrote {path}")
221 return 0
224if __name__ == "__main__":
225 sys.exit(main())