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

1"""Capture Grafana dashboard screenshots for the monitoring docs. 

2 

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: 

8 

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 

14 

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" 

19 

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. 

25 

26The native OpenCost UI (not a Grafana dashboard) is captured too when 

27``--opencost-url`` is passed. It rides a second port-forward:: 

28 

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""" 

35 

36from __future__ import annotations 

37 

38import argparse 

39import base64 

40import sys 

41from dataclasses import dataclass 

42from pathlib import Path 

43 

44PROJECT_ROOT = Path(__file__).resolve().parent.parent 

45IMAGES_DIR = PROJECT_ROOT / "images" 

46DEFAULT_GRAFANA_URL = "http://localhost:3000" 

47 

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 

52 

53 

54@dataclass(frozen=True) 

55class Screenshot: 

56 """One dashboard capture: its Grafana ``uid`` and the output filename.""" 

57 

58 dashboard_uid: str 

59 filename: str 

60 title: str 

61 

62 

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) 

73 

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" 

78 

79 

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] 

83 

84 

85def capture_opencost_ui(opencost_url: str, output_dir: Path) -> Path: 

86 """Screenshot the native OpenCost UI. Returns the written path. 

87 

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 

94 

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 

108 

109 

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. 

119 

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. 

128 

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. 

135 

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 

140 

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 

171 

172 

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) 

203 

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 

218 

219 for path in written: 

220 print(f"wrote {path}") 

221 return 0 

222 

223 

224if __name__ == "__main__": 

225 sys.exit(main())