diff --git a/grafana/dashboards/github-prs.json b/grafana/dashboards/github-prs.json index 2c64ba9c6..9e4c40ee0 100644 --- a/grafana/dashboards/github-prs.json +++ b/grafana/dashboards/github-prs.json @@ -13,11 +13,18 @@ { "name": "scope", "label": "Scope", - "type": "custom", - "query": "All repos : org:JSONbored, metagraphed : repo:JSONbored/metagraphed, gittensory : repo:JSONbored/gittensory, awesome-claude : repo:JSONbored/awesome-claude, metagraphed-ui : repo:JSONbored/metagraphed-ui", + "type": "query", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, + "description": "Dynamic, never hardcoded: built from whichever repos are actually tracked in this self-hoster's own reporting DB. 'All repos' assumes a single-owner setup (the same assumption the dashboard's own GitHub-search-syntax scope always required, since GitHub search can't OR multiple repo: qualifiers in one query) — it widens to org: of the first tracked repo. A self-hoster tracking repos across multiple owners should pick a specific repo instead of 'All repos'.", + "query": { + "queryType": "table", + "rawQueryText": "SELECT * FROM (SELECT 0 AS ord, 'All repos' AS \"__text\", (SELECT 'org:' || substr(repo, 1, instr(repo, '/') - 1) FROM review_targets LIMIT 1) AS \"__value\" UNION ALL SELECT 1 AS ord, repo AS \"__text\", 'repo:' || repo AS \"__value\" FROM (SELECT DISTINCT repo FROM review_targets ORDER BY repo)) ORDER BY ord, \"__text\"" + }, "current": { "text": "All repos", "value": "org:JSONbored" }, "includeAll": false, - "multi": false + "multi": false, + "refresh": 2, + "sort": 0 } ] }, diff --git a/grafana/dashboards/gittensory.json b/grafana/dashboards/gittensory.json index ad1933703..1ccd1ab91 100644 --- a/grafana/dashboards/gittensory.json +++ b/grafana/dashboards/gittensory.json @@ -507,42 +507,6 @@ } }, "gridPos": { "h": 4, "w": 6, "x": 0, "y": 33 }, - "id": 13, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "title": "Orb Events Recorded", - "type": "stat", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "expr": "gittensory_orb_events_recorded_total or vector(0)", - "legendFormat": "recorded" - } - ] - }, - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - }, - "unit": "short" - } - }, - "gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 }, "id": 14, "options": { "colorMode": "value", @@ -581,7 +545,7 @@ "unit": "short" } }, - "gridPos": { "h": 4, "w": 6, "x": 12, "y": 33 }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 }, "id": 15, "options": { "colorMode": "background", @@ -605,42 +569,6 @@ } ] }, - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - }, - "unit": "short" - } - }, - "gridPos": { "h": 4, "w": 6, "x": 18, "y": 33 }, - "id": 16, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "title": "Orb Installations", - "type": "stat", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "expr": "gittensory_orb_installs_total or vector(0)", - "legendFormat": "installs" - } - ] - }, { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "fieldConfig": { @@ -650,7 +578,7 @@ "unit": "ops" } }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 37 }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 37 }, "id": 17, "options": { "legend": { @@ -663,11 +591,6 @@ "title": "Orb Event Rate", "type": "timeseries", "targets": [ - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "expr": "rate(gittensory_orb_events_recorded_total[5m]) or vector(0)", - "legendFormat": "recorded/s" - }, { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(gittensory_orb_events_exported_total[5m]) or vector(0)", @@ -680,40 +603,6 @@ } ] }, - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { "lineWidth": 2, "fillOpacity": 10 }, - "unit": "short" - } - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 37 }, - "id": 18, - "options": { - "legend": { - "calcs": ["mean", "last"], - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { "mode": "multi" } - }, - "title": "Orb Pending vs Exported", - "type": "timeseries", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "expr": "(gittensory_orb_events_recorded_total or vector(0)) - (gittensory_orb_events_exported_total or vector(0))", - "legendFormat": "pending" - }, - { - "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "expr": "gittensory_orb_events_exported_total or vector(0)", - "legendFormat": "exported (cumulative)" - } - ] - }, { "collapsed": false, "gridPos": { @@ -2504,7 +2393,7 @@ ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 170 }, - "id": 158, + "id": 205, "options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } diff --git a/prometheus/rules/alerts.yml b/prometheus/rules/alerts.yml index b5b6f5f44..8543bf46a 100644 --- a/prometheus/rules/alerts.yml +++ b/prometheus/rules/alerts.yml @@ -411,12 +411,12 @@ groups: description: "Either the consecutive registration-failure streak has reached {{ $value | printf \"%.0f\" }}, or the pull-mode drain loop hasn't completed in over 30m. A lone registration timeout alone would not trigger this." runbook: "Check gittensory_orb_relay_register_total{result=\"failed\"} by mode for the failure pattern, and confirm ORB_BROKER_URL / ORB_ENROLLMENT_SECRET are still valid. If pull mode, verify the drain loop itself isn't crash-looping (selfhost_orb_relay_register_failed logs at level=error)." - # ── HTTP serving health (uses the PLANNED status label + duration histogram) ─ - # NOTE: gittensory_http_requests_total is gaining a status="2xx|3xx|4xx|5xx" label, - # and gittensory_http_request_duration_seconds (a histogram) is being added. Both - # rules below tolerate the metric/label being absent on older data: the ratio uses a - # `> 0` denominator guard, and an absent series simply yields no result (rule stays - # inactive) rather than erroring. They start firing the moment the new metrics exist. + # ── HTTP serving health (status label + duration histogram, both live in src/server.ts) ─ + # gittensory_http_requests_total carries a status="2xx|3xx|4xx|5xx" label (seeded at zero per + # class so every series exists from boot), and gittensory_http_request_duration_seconds is a + # histogram of request duration. Both rules below still tolerate an absent metric/label on an + # older running build: the ratio uses a `> 0` denominator guard, and an absent series simply + # yields no result (rule stays inactive) rather than erroring. - name: gittensory-http rules: - alert: GittensoryHighHttp5xxRatio diff --git a/scripts/setup-github-datasource.sh b/scripts/setup-github-datasource.sh index 8806706c7..6d7a2cefd 100755 --- a/scripts/setup-github-datasource.sh +++ b/scripts/setup-github-datasource.sh @@ -3,6 +3,14 @@ # maintainer dashboards. Done over the API rather than file-provisioning on purpose: a backend datasource # whose plugin/token isn't ready at boot would crash Grafana's provisioning, so we add it after Grafana is up. # +# KNOWN, ACCEPTED trade-off: unlike the other 4 file-provisioned datasources (each `editable: false` in their +# YAML), this one is API-managed and therefore NOT locked read-only — Grafana's `readOnly` field is computed +# from whether a datasource came from file provisioning and cannot be set via the datasource API itself +# (verified empirically: a PUT with `"readOnly": true` in the payload is silently ignored, the response still +# reports `false`). Moving this to YAML provisioning to close that gap would reintroduce the exact boot-crash +# risk documented above, so this datasource's connection settings remain editable via the Grafana UI, unlike +# every other one in this stack. +# # Prereqs: --profile observability running, the grafana-github-datasource plugin installed (GF_INSTALL_PLUGINS), # and a read-only fine-grained PAT (Pull requests: read, Issues: read, Contents: read) on the repos. # diff --git a/test/unit/selfhost-grafana-dashboard.test.ts b/test/unit/selfhost-grafana-dashboard.test.ts index a1b0339c4..b1d7bdc18 100644 --- a/test/unit/selfhost-grafana-dashboard.test.ts +++ b/test/unit/selfhost-grafana-dashboard.test.ts @@ -31,6 +31,7 @@ const tmpRoots: string[] = []; const dashboardPath = join(process.cwd(), "grafana/dashboards/maintainer-reviews.json"); const selfhostDashboardPath = join(process.cwd(), "grafana/dashboards/gittensory.json"); const selfhostAlertsPath = join(process.cwd(), "prometheus/rules/alerts.yml"); +const githubPrsPath = join(process.cwd(), "grafana/dashboards/github-prs.json"); const timeFrom = "${__from:date:seconds}"; const timeTo = "${__to:date:seconds}"; @@ -118,11 +119,31 @@ describe("Gittensory Self-Host Grafana dashboard", () => { const dashboard = readDashboard(selfhostDashboardPath); const targets = dashboard.panels.flatMap((panel) => panel.targets ?? []); - expect(targets.some((target) => target.expr === "gittensory_orb_events_recorded_total or vector(0)")).toBe(true); expect(targets.some((target) => target.expr === "gittensory_orb_events_exported_total or vector(0)")).toBe(true); - expect(targets.some((target) => target.expr === "gittensory_orb_installs_total or vector(0)")).toBe(true); + expect(targets.some((target) => target.expr === "gittensory_orb_export_errors_total or vector(0)")).toBe(true); expect(targets.some((target) => target.expr === "sum by (result) (rate(gittensory_orb_webhook_total[5m])) or vector(0)")).toBe(true); - expect(targets.some((target) => target.expr === "(gittensory_orb_events_recorded_total or vector(0)) - (gittensory_orb_events_exported_total or vector(0))")).toBe(true); + }); + + it("no longer references gittensory_orb_events_recorded_total / gittensory_orb_installs_total, retired with the per-instance Orb App in #1256 but never cleaned out of the dashboard (2026-07 fix)", () => { + const dashboard = readDashboard(selfhostDashboardPath); + const targets = dashboard.panels.flatMap((panel) => panel.targets ?? []); + const titles = dashboard.panels.map((panel) => panel.title); + + for (const target of targets) { + expect(target.expr ?? "").not.toContain("gittensory_orb_events_recorded_total"); + expect(target.expr ?? "").not.toContain("gittensory_orb_installs_total"); + } + expect(titles).not.toContain("Orb Events Recorded"); + expect(titles).not.toContain("Orb Installations"); + expect(titles).not.toContain("Orb Pending vs Exported"); + }); + + it("assigns every panel a unique id (regression: 'Maintenance Admission Deferrals (total)' and 'Orb Relay Registration: Streak vs Drain Progress' both used id 158, 2026-07 fix)", () => { + const dashboard = readDashboard(selfhostDashboardPath); + const ids = dashboard.panels.map((panel) => panel.id).filter((id): id is number => id !== undefined); + const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); + + expect(duplicates).toEqual([]); }); it("surfaces the onMerge/combine/reviewer-count floor-clamp counter on a panel and an alert (#3901)", () => { @@ -499,3 +520,39 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { expect(tableRows).not.toContain("cut v1.0.0"); }); }); + +describe("github-prs.json: $scope is dynamic, never a hardcoded repo list (2026-07 fix)", () => { + function readGithubPrsDashboard(): { + templating: { list: Array<{ name: string; type: string; datasource?: { type?: string }; query?: { rawQueryText?: string } }> }; + } { + return JSON.parse(readFileSync(githubPrsPath, "utf8")); + } + + it("declares $scope as a dynamic, query-backed variable against the local reporting DB, not a hardcoded custom list", () => { + const vars = readGithubPrsDashboard().templating.list; + const scope = vars.find((v) => v.name === "scope"); + + expect(scope?.type).toBe("query"); + expect(scope?.datasource?.type).toBe("frser-sqlite-datasource"); + expect(scope?.query?.rawQueryText).toContain("review_targets"); + expect(scope?.query?.rawQueryText).not.toContain("JSONbored"); + }); + + (sqliteCliAvailable ? it : it.skip)("builds 'All repos' as org: of the first tracked repo, plus one repo: option per distinct tracked repo", () => { + const root = tmpRoot(); + const db = join(root, "reporting.sqlite"); + sqlite( + db, + ` + CREATE TABLE review_targets (repo TEXT NOT NULL); + INSERT INTO review_targets (repo) VALUES ('acme/widgets'), ('acme/gadgets'), ('acme/widgets'); + `, + ); + + const rawQueryText = readGithubPrsDashboard().templating.list.find((v) => v.name === "scope")?.query?.rawQueryText; + if (!rawQueryText) throw new Error("missing $scope rawQueryText"); + const rows = sqlite(db, rawQueryText).split("\n"); + + expect(rows).toEqual(["0|All repos|org:acme", "1|acme/gadgets|repo:acme/gadgets", "1|acme/widgets|repo:acme/widgets"]); + }); +});