diff --git a/README.md b/README.md index 682c814..3005e2e 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,59 @@ repo whose default branch had moved. `jq_github_rate_limit_remaining` is on the headroom is visible rather than assumed. Lower `JQ_GITHUB_INTERVAL` only if that number stays comfortable. +## Serving it world-readable + +The default stack is a private, loopback-only tool. Making it public needs two +separate things — clean **data** and a locked-down **access mode** — and getting +only the first right leaks. + +**1. Data.** `JQ_PUBLIC_ONLY=true` (already set in `.env`) drops private repos +completely: not just their details, but their existence. A private repo's name, +its workflow names, its PR titles and its local branch names are all disclosure. +Purge whatever was collected before you set it: + +```bash +./scripts/purge-repo.sh Jebel-Quant/some-private-repo ... +``` + +**2. Access mode.** Purging is not enough on its own. With anonymous access on, +a visitor can POST arbitrary PromQL to `/api/ds/query` and read the raw label +index through `/api/datasources/proxy` — and **purged names linger in that index +for hours**, until head compaction. Measured on this stack: after deleting all +seven private repos, the index still returned every one of their names, and a +restart did not clear them. + +So serve it through Grafana's public-dashboard link, which runs only that one +dashboard's queries and exposes no datasource: + +```bash +docker compose -f docker-compose.yml -f docker-compose.public.yml up -d +./scripts/check-public-safe.sh # must pass before you expose anything +``` + +The overlay turns anonymous access off and public dashboards on. The preflight +verifies every exported repo really is public *on GitHub* (not merely labelled +so here), that anonymous queries and the datasource proxy are both refused, and +that Prometheus and the collector are still loopback-only. It exits non-zero if +not. + +Then open the board as `admin`, *Share → Public dashboard*, and share only that +link. Verified behaviour: the public link and its own panel queries return 200; +`/api/search`, `/api/ds/query` and the datasource proxy all return 401. + +**Public dashboards do not resolve template variables**, so the `$repo` picker +would leave every panel showing "No data". `scripts/make-public-dashboard.py` +generates `fleet-public.json` from `fleet.json` with the variable and its +selectors stripped — equivalent queries, because the collector is already +restricted to public repos. Re-run it after changing `fleet.json`. + +Nothing is on the internet until you put it there. The public URL is still only +reachable on `localhost` — a tunnel (Cloudflare, Tailscale Funnel) or a host with +a public address is a separate, deliberate step. + +To go back to the convenient local setup, drop the overlay: +`docker compose up -d`. + ## Alerting Six rules are provisioned into the `Jebel-Quant` folder, each multi-dimensional diff --git a/collector/jq_collector/config.py b/collector/jq_collector/config.py index 2c66e0e..5f545d2 100644 --- a/collector/jq_collector/config.py +++ b/collector/jq_collector/config.py @@ -65,6 +65,11 @@ class Config: os.environ.get("JQ_INCLUDE_ARCHIVED", "false").lower() == "true" ) + # Drop private repos entirely - not just their details, but their existence. + # For a board served world-readable, a private repo's name, its workflow + # names, its PR titles and its local branch names are all disclosure. + public_only: bool = os.environ.get("JQ_PUBLIC_ONLY", "false").lower() == "true" + @property def owners(self) -> frozenset[str]: """Every owner we might accept a clone from, lowercased.""" diff --git a/docker-compose.public.yml b/docker-compose.public.yml new file mode 100644 index 0000000..ecd15ce --- /dev/null +++ b/docker-compose.public.yml @@ -0,0 +1,25 @@ +# Overlay for serving the board world-readable: +# +# docker compose -f docker-compose.yml -f docker-compose.public.yml up -d +# ./scripts/check-public-safe.sh # must pass before you expose anything +# +# Anonymous access is turned OFF and Grafana's public-dashboard feature is +# turned ON. That combination matters more than it looks. With anonymous Viewer +# enabled, a visitor can POST arbitrary PromQL to /api/ds/query and read the raw +# label index through /api/datasources/proxy - which lists private repo names +# for hours after their data has been purged, because tombstoned index entries +# survive until head compaction. A public dashboard link serves only that one +# dashboard's own queries, with no datasource access behind it. +# +# Publish by opening the dashboard as admin -> Share -> Public dashboard. +services: + grafana: + environment: + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_PUBLIC_DASHBOARDS_ENABLED: "true" + # Belt and braces: no sign-up, no Explore for low-privilege roles, and the + # snapshot feature off so nothing can be re-published elsewhere. + GF_USERS_ALLOW_SIGN_UP: "false" + GF_USERS_VIEWERS_CAN_EDIT: "false" + GF_EXPLORE_ENABLED: "false" + GF_SNAPSHOTS_EXTERNAL_ENABLED: "false" diff --git a/grafana/dashboards/fleet-public.json b/grafana/dashboards/fleet-public.json new file mode 100644 index 0000000..6e23ad1 --- /dev/null +++ b/grafana/dashboards/fleet-public.json @@ -0,0 +1,2914 @@ +{ + "uid": "jq-fleet-public", + "title": "Jebel-Quant Fleet (public)", + "description": "Public copy of the fleet board. Covers public repos only - the collector runs with JQ_PUBLIC_ONLY, so private repos are never gathered.", + "tags": [ + "fleet", + "jebel-quant", + "public" + ], + "editable": false, + "schemaVersion": 39, + "version": 1, + "refresh": "1m", + "time": { + "from": "now-7d", + "to": "now" + }, + "timezone": "browser", + "graphTooltip": 1, + "templating": { + "list": [] + }, + "panels": [ + { + "type": "row", + "title": "Fleet at a glance", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "collapsed": false, + "panels": [], + "id": 1 + }, + { + "type": "stat", + "title": "Repos monitored", + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 1 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "count(jq_repo_info)", + "instant": true + } + ], + "options": { + "colorMode": "none", + "graphMode": "none", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0 + }, + "overrides": [] + }, + "id": 2 + }, + { + "type": "stat", + "title": "CI red on main", + "description": "Repos whose last completed run on the default branch did not pass.", + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 1 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_ci_last_run_success == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#d03b3b", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=101&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 3 + }, + { + "type": "stat", + "title": "Behind template", + "description": "Repos pinned to a template release older than the newest one.", + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 1 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_rhiza_releases_behind > bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + }, + { + "color": "#ec835a", + "value": 5 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=102&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 4 + }, + { + "type": "stat", + "title": "Dirty working copies", + "description": "Local clones with uncommitted changes to tracked files.", + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_local_dirty_files > bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=103&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 5 + }, + { + "type": "stat", + "title": "PRs with red checks", + "gridPos": { + "h": 5, + "w": 4, + "x": 20, + "y": 1 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_open_pull_requests_failing)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#d03b3b", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=104&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 6 + }, + { + "type": "stat", + "title": "Clones off default branch", + "description": "A branch checked out and left behind - the usual residue of a parallel session.", + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_local_on_default_branch == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=105&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 7 + }, + { + "type": "row", + "title": "Template drift", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 11 + }, + "collapsed": false, + "panels": [], + "id": 8 + }, + { + "type": "bargauge", + "title": "Template releases behind", + "description": "How many releases were published after each repo's pinned ref. Repos pinned to a branch or a sha are absent - the pin is not comparable to a release.", + "gridPos": { + "h": 13, + "w": 10, + "x": 0, + "y": 12 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sort_desc(jq_rhiza_releases_behind)", + "instant": true, + "legendFormat": "{{repo}}" + } + ], + "options": { + "orientation": "horizontal", + "displayMode": "basic", + "valueMode": "text", + "showUnfilled": true, + "minVizWidth": 8, + "minVizHeight": 14, + "namePlacement": "left", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "min": 0, + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#184f95", + "value": null + }, + { + "color": "#256abf", + "value": 1 + }, + { + "color": "#3987e5", + "value": 2 + }, + { + "color": "#6da7ec", + "value": 3 + }, + { + "color": "#9ec5f4", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "id": 9 + }, + { + "type": "table", + "title": "Pinned ref per repo", + "gridPos": { + "h": 13, + "w": 14, + "x": 10, + "y": 12 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_rhiza_template_ref_info", + "instant": true, + "format": "table" + }, + { + "refId": "B", + "expr": "jq_rhiza_releases_behind", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "repo", + "mode": "outer" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time 1": true, + "Time 2": true, + "Value #A": true, + "__name__ 1": true, + "__name__ 2": true, + "instance 1": true, + "instance 2": true, + "job 1": true, + "job 2": true, + "fleet 1": true, + "fleet 2": true + }, + "renameByName": { + "repo": "Repo", + "ref": "Pinned ref", + "Value #B": "Releases behind" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Releases behind", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Releases behind" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 140 + }, + { + "id": "noValue", + "value": "not a release" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "id": 10 + }, + { + "type": "row", + "title": "Trend and CI history", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "collapsed": false, + "panels": [], + "id": 11 + }, + { + "type": "timeseries", + "title": "Open problems by kind", + "description": "How many repos are in each bad state over time. Three distinct kinds, so the colours carry identity, not severity.", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 26 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_ci_last_run_success == bool 0)", + "legendFormat": "CI red on main" + }, + { + "refId": "B", + "expr": "sum(jq_rhiza_releases_behind > bool 0)", + "legendFormat": "Behind template" + }, + { + "refId": "C", + "expr": "sum(jq_local_dirty_files > bool 0)", + "legendFormat": "Dirty working copies" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "min": 0, + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 0, + "showPoints": "never", + "pointSize": 8, + "spanNulls": true, + "axisGridShow": true, + "axisBorderShow": false + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "CI red on main" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#3987e5" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Behind template" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d95926" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Dirty working copies" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#199e70" + } + } + ] + } + ] + }, + "id": 12 + }, + { + "type": "state-timeline", + "title": "CI on the default branch, per repo", + "description": "One row per repo; green means the last completed run on the default branch passed. Owner prefixes are stripped for legibility - repo names are unique across the monitored orgs. Gaps are windows where the collector was not running.", + "gridPos": { + "h": 20, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_ci_last_run_success", + "legendFormat": "{{repo}}", + "range": true, + "instant": false + } + ], + "options": { + "showValue": "never", + "rowHeight": 0.7, + "mergeValues": true, + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "lineWidth": 0, + "fillOpacity": 90 + }, + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "Failing", + "index": 0 + }, + "1": { + "text": "Passing", + "index": 1 + } + } + } + ], + "noValue": "no run", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#d03b3b", + "value": null + }, + { + "color": "#0ca30c", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "id": 13, + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^[^/]+/(.*)$", + "renamePattern": "$1" + } + } + ] + }, + { + "type": "row", + "title": "Detail", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 54 + }, + "collapsed": false, + "panels": [], + "id": 14 + }, + { + "type": "table", + "title": "Last completed run on the default branch", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_ci_last_run_info", + "instant": true, + "format": "table" + }, + { + "refId": "B", + "expr": "time() - jq_ci_last_run_timestamp_seconds", + "instant": true, + "format": "table" + }, + { + "refId": "C", + "expr": "jq_ci_last_run_duration_seconds", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "repo", + "mode": "outer" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time 1": true, + "Time 2": true, + "Time 3": true, + "Value #A": true, + "__name__ 1": true, + "__name__ 2": true, + "__name__ 3": true, + "instance 1": true, + "instance 2": true, + "instance 3": true, + "job 1": true, + "job 2": true, + "job 3": true, + "fleet 1": true, + "fleet 2": true, + "fleet 3": true + }, + "renameByName": { + "repo": "Repo", + "conclusion": "Conclusion", + "workflow": "Workflow", + "Value #B": "Finished", + "Value #C": "Took" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Conclusion", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Finished" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 130 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Took" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Conclusion" + }, + "properties": [ + { + "id": "custom.width", + "value": 150 + }, + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "success": { + "text": "\u2713 success", + "color": "#0ca30c", + "index": 0 + } + } + }, + { + "type": "value", + "options": { + "failure": { + "text": "\u2717 failure", + "color": "#d03b3b", + "index": 1 + } + } + }, + { + "type": "value", + "options": { + "cancelled": { + "text": "\u25cb cancelled", + "color": "#ec835a", + "index": 2 + } + } + }, + { + "type": "value", + "options": { + "timed_out": { + "text": "\u2717 timed out", + "color": "#d03b3b", + "index": 3 + } + } + }, + { + "type": "value", + "options": { + "startup_failure": { + "text": "\u2717 startup failure", + "color": "#d03b3b", + "index": 4 + } + } + }, + { + "type": "value", + "options": { + "skipped": { + "text": "\u2013 skipped", + "color": "#898781", + "index": 5 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Actions on GitHub", + "url": "https://github.com/${__value.text}/actions", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "id": 15 + }, + { + "type": "table", + "title": "Open pull requests", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 69 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "(time() - jq_pull_request_created_timestamp_seconds) * on(repo, number) group_left(title, author, checks, draft) topk by (repo, number) (1, jq_pull_request_info)", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "number": 1, + "title": 2, + "author": 3, + "checks": 4, + "draft": 5, + "Value": 6 + }, + "renameByName": { + "repo": "Repo", + "number": "#", + "title": "Title", + "author": "Author", + "checks": "Checks", + "draft": "Draft", + "Value": "Age" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Age", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Age" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "custom.align", + "value": "right" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "#" + }, + "properties": [ + { + "id": "custom.width", + "value": 70 + }, + { + "id": "links", + "value": [ + { + "title": "Open this pull request on GitHub", + "url": "https://github.com/${__data.fields.Repo}/pull/${__data.fields[\"#\"]}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Checks" + }, + "properties": [ + { + "id": "custom.width", + "value": 130 + }, + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "success": { + "text": "\u2713 green", + "color": "#0ca30c", + "index": 0 + } + } + }, + { + "type": "value", + "options": { + "failure": { + "text": "\u2717 red", + "color": "#d03b3b", + "index": 1 + } + } + }, + { + "type": "value", + "options": { + "pending": { + "text": "\u25cf running", + "color": "#fab219", + "index": 2 + } + } + }, + { + "type": "value", + "options": { + "cancelled": { + "text": "\u25cb cancelled", + "color": "#ec835a", + "index": 3 + } + } + }, + { + "type": "value", + "options": { + "none": { + "text": "\u2013 none", + "color": "#898781", + "index": 4 + } + } + }, + { + "type": "value", + "options": { + "unknown": { + "text": "? unknown", + "color": "#898781", + "index": 5 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Title" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + }, + { + "id": "links", + "value": [ + { + "title": "Open this pull request on GitHub", + "url": "https://github.com/${__data.fields.Repo}/pull/${__data.fields[\"#\"]}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.align", + "value": "left" + }, + { + "id": "custom.width", + "value": 240 + }, + { + "id": "links", + "value": [ + { + "title": "Open the repo's pull requests on GitHub", + "url": "https://github.com/${__value.text}/pulls", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "id": 16 + }, + { + "type": "table", + "title": "Local working copies", + "description": "Ahead and behind are as of each clone's last fetch - read them next to the fetch age. 'In sync' compares the local default-branch commit with the one GitHub reports and needs no fetch. 'Checkout ref' is the template ref pinned in *this clone*, which can lag the repo's own ref shown under Template drift.", + "gridPos": { + "h": 16, + "w": 24, + "x": 0, + "y": 81 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_local_branch_info", + "instant": true, + "format": "table" + }, + { + "refId": "B", + "expr": "jq_local_dirty_files", + "instant": true, + "format": "table" + }, + { + "refId": "C", + "expr": "jq_local_untracked_files", + "instant": true, + "format": "table" + }, + { + "refId": "D", + "expr": "jq_local_ahead_commits", + "instant": true, + "format": "table" + }, + { + "refId": "E", + "expr": "jq_local_behind_commits", + "instant": true, + "format": "table" + }, + { + "refId": "F", + "expr": "jq_local_default_branch_synced", + "instant": true, + "format": "table" + }, + { + "refId": "G", + "expr": "jq_local_fetch_age_seconds", + "instant": true, + "format": "table" + }, + { + "refId": "H", + "expr": "jq_local_stash_entries", + "instant": true, + "format": "table" + }, + { + "refId": "I", + "expr": "jq_local_template_ref_info", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "repo", + "mode": "outer" + } + }, + { + "id": "filterFieldsByName", + "options": { + "include": { + "pattern": "^(repo|branch|ref|Value #B|Value #C|Value #D|Value #E|Value #F|Value #G|Value #H)$" + } + } + }, + { + "id": "organize", + "options": { + "excludeByName": {}, + "indexByName": { + "repo": 0, + "branch": 1, + "Value #B": 3, + "Value #C": 4, + "Value #D": 5, + "Value #E": 6, + "Value #F": 7, + "Value #G": 8, + "Value #H": 9, + "ref": 2 + }, + "renameByName": { + "repo": "Repo", + "branch": "Branch", + "Value #B": "Dirty", + "Value #C": "Untracked", + "Value #D": "Ahead", + "Value #E": "Behind", + "Value #F": "In sync", + "Value #G": "Last fetch", + "Value #H": "Stashes", + "ref": "Checkout ref" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Dirty", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "right", + "cellOptions": { + "type": "auto" + } + }, + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.align", + "value": "left" + }, + { + "id": "custom.width", + "value": 240 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Branch" + }, + "properties": [ + { + "id": "custom.align", + "value": "left" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Dirty" + }, + "properties": [ + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#898781", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + } + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "In sync" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "1": { + "text": "\u2713 yes", + "color": "#0ca30c", + "index": 0 + } + } + }, + { + "type": "value", + "options": { + "0": { + "text": "\u2717 no", + "color": "#fab219", + "index": 1 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "noValue", + "value": "\u2013" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Last fetch" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "noValue", + "value": "never" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "Ahead|Behind" + }, + "properties": [ + { + "id": "noValue", + "value": "no upstream" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Checkout ref" + }, + "properties": [ + { + "id": "custom.align", + "value": "left" + }, + { + "id": "custom.width", + "value": 130 + }, + { + "id": "noValue", + "value": "\u2013" + } + ] + } + ] + }, + "id": 17 + }, + { + "id": 100, + "type": "row", + "title": "Drill-down (click a tile above)", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 97 + }, + "collapsed": true, + "panels": [ + { + "id": 101, + "type": "table", + "title": "Failing workflows on the default branch", + "description": "One row per failing workflow, not per repo - a repo can have more than one red workflow, so this list can be longer than the 'CI red on main' tile, which counts repos.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "(time() - jq_ci_workflow_timestamp_seconds) and on(repo, workflow) (jq_ci_workflow_success == 0)", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "workflow": 1, + "Value": 2 + }, + "renameByName": { + "repo": "Repo", + "workflow": "Workflow", + "Value": "Failing since" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Failing since", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open Actions on GitHub", + "url": "https://github.com/${__value.text}/actions", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failing since" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 160 + } + ] + } + ] + } + }, + { + "id": 102, + "type": "table", + "title": "Repos behind the template", + "description": "Every repo pinned to an older template release. Matches the 'Behind template' tile.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "(jq_rhiza_releases_behind > 0) * on(repo) group_left(ref) topk by (repo) (1, jq_rhiza_template_ref_info)", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true, + "Value": false + }, + "indexByName": { + "repo": 0, + "ref": 1, + "Value": 2 + }, + "renameByName": { + "repo": "Repo", + "ref": "Pinned ref", + "Value": "Releases behind" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Releases behind", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Releases behind" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 160 + } + ] + } + ] + } + }, + { + "id": 103, + "type": "table", + "title": "Working copies with uncommitted changes", + "description": "Matches the 'Dirty working copies' tile. Counts tracked files only; untracked files are in the main table.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "(jq_local_dirty_files > 0) * on(repo) group_left(branch) topk by (repo) (1, jq_local_branch_info)", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true, + "Value": false + }, + "indexByName": { + "repo": 0, + "branch": 1, + "Value": 2 + }, + "renameByName": { + "repo": "Repo", + "branch": "Branch", + "Value": "Dirty files" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Dirty files", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Dirty files" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 140 + } + ] + } + ] + } + }, + { + "id": 104, + "type": "table", + "title": "Open pull requests with red checks", + "description": "Matches the 'PRs with red checks' tile.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "(time() - jq_pull_request_created_timestamp_seconds) * on(repo, number) group_left(title, author, checks) topk by (repo, number) (1, jq_pull_request_info{checks=~\"failure|cancelled\"})", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true, + "Value": false + }, + "indexByName": { + "repo": 0, + "number": 1, + "title": 2, + "author": 3, + "checks": 4, + "Value": 5 + }, + "renameByName": { + "repo": "Repo", + "number": "#", + "title": "Title", + "author": "Author", + "checks": "Checks", + "Value": "Age" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Age", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open the repo's pull requests on GitHub", + "url": "https://github.com/${__value.text}/pulls", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Age" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 140 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "#" + }, + "properties": [ + { + "id": "custom.width", + "value": 70 + }, + { + "id": "links", + "value": [ + { + "title": "Open this pull request on GitHub", + "url": "https://github.com/${__data.fields.Repo}/pull/${__data.fields[\"#\"]}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Title" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open this pull request on GitHub", + "url": "https://github.com/${__data.fields.Repo}/pull/${__data.fields[\"#\"]}", + "targetBlank": true + } + ] + } + ] + } + ] + } + }, + { + "id": 105, + "type": "table", + "title": "Clones parked off their default branch", + "description": "Matches the 'Clones off default branch' tile. The value column is omitted - the branch name is the point.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "topk by (repo) (1, jq_local_branch_info) and on(repo) (jq_local_on_default_branch == 0)", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true, + "Value": true + }, + "indexByName": { + "repo": 0, + "branch": 1 + }, + "renameByName": { + "repo": "Repo", + "branch": "Branch" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + } + ] + } + }, + { + "id": 106, + "type": "table", + "title": "Local clones not in sync with GitHub", + "description": "The local default-branch commit differs from GitHub's. Sorted stalest fetch first - a plain `git pull` clears almost all of these.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "(jq_local_fetch_age_seconds and on(repo) (jq_local_default_branch_synced == 0)) * on(repo) group_left(branch) topk by (repo) (1, jq_local_branch_info)", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "branch": 1, + "Value": 2 + }, + "renameByName": { + "repo": "Repo", + "branch": "Branch", + "Value": "Last fetch" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Last fetch", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Last fetch" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 107, + "type": "table", + "title": "Open issues by repo", + "description": "Repos with at least one open issue, pull requests excluded.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_open_issues > 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Open issues" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Open issues", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Open issues" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + } + ] + }, + { + "type": "row", + "title": "Collector health", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 98 + }, + "collapsed": true, + "panels": [ + { + "type": "stat", + "title": "GitHub rate limit remaining", + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 99 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_github_rate_limit_remaining", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#d03b3b", + "value": null + }, + { + "color": "#fab219", + "value": 500 + }, + { + "color": "#0ca30c", + "value": 1500 + } + ] + } + }, + "overrides": [] + }, + "id": 527 + }, + { + "type": "stat", + "title": "Since last GitHub refresh", + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 99 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "time() - jq_collector_last_success_timestamp_seconds{source=\"github\"}", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 900 + }, + { + "color": "#d03b3b", + "value": 3600 + } + ] + } + }, + "overrides": [] + }, + "id": 528 + }, + { + "type": "timeseries", + "title": "Refresh duration", + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 99 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_collector_refresh_duration_seconds", + "legendFormat": "{{source}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 0, + "showPoints": "never" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "github" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#3987e5" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "local" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d95926" + } + } + ] + } + ] + }, + "id": 529 + } + ], + "id": 526 + }, + { + "type": "stat", + "title": "Local repos not in sync", + "description": "Clones whose default-branch commit differs from the one GitHub reports - almost always a checkout that has not been pulled. Needs no fetch to compute, so unlike Ahead/Behind it is never stale.", + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_local_default_branch_synced == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=106&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 18 + }, + { + "type": "stat", + "title": "Open PRs", + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 1 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_open_pull_requests)", + "instant": true + } + ], + "options": { + "colorMode": "none", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "links": [ + { + "title": "Show the breakdown by repo", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=16&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "description": "Open pull requests across the fleet, counted before any per-repo clipping.", + "id": 531 + }, + { + "type": "stat", + "title": "Open issues", + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 1 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_open_issues)", + "instant": true + } + ], + "options": { + "colorMode": "none", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "links": [ + { + "title": "Show the breakdown by repo", + "url": "/d/jq-fleet-public/jebel-quant-fleet-public?viewPanel=107&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "description": "Open issues across the fleet, excluding pull requests - GitHub's own open_issues_count lumps the two together, so PRs are subtracted out.", + "id": 532 + }, + { + "type": "stat", + "title": "Data age", + "description": "How long since the collector last refreshed, worst of its two sources. This machine's Docker pauses when the Mac sleeps, so nothing is collected then - an amber or red value here means the numbers above are from before the last sleep, and panels can read 'No data' entirely once the gap passes Prometheus's 5-minute staleness window.", + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "time() - min(jq_collector_last_success_timestamp_seconds)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 600 + }, + { + "color": "#d03b3b", + "value": 1800 + } + ] + } + }, + "overrides": [] + }, + "id": 19 + } + ] +} diff --git a/scripts/check-dashboard.py b/scripts/check-dashboard.py index dce36ad..14c44cb 100755 --- a/scripts/check-dashboard.py +++ b/scripts/check-dashboard.py @@ -10,8 +10,8 @@ import pathlib import sys -path = pathlib.Path(__file__).resolve().parent.parent / "grafana/dashboards/fleet.json" -dash = json.loads(path.read_text()) +HERE = pathlib.Path(__file__).resolve().parent.parent +paths = sorted((HERE / "grafana/dashboards").glob("*.json")) def walk(panels): @@ -20,49 +20,54 @@ def walk(panels): yield from walk(panel.get("panels", [])) -panels = list(walk(dash["panels"])) -ids = [p["id"] for p in panels] -problems = [] +all_problems = [] +for path in paths: + dash = json.loads(path.read_text()) + panels = list(walk(dash["panels"])) + ids = [p["id"] for p in panels] + problems = [] -for pid in {i for i in ids if ids.count(i) > 1}: - titles = ", ".join(repr(p.get("title")) for p in panels if p["id"] == pid) - problems.append(f"duplicate panel id {pid}: {titles}") + for pid in {i for i in ids if ids.count(i) > 1}: + titles = ", ".join(repr(p.get("title")) for p in panels if p["id"] == pid) + problems.append(f"duplicate panel id {pid}: {titles}") -targets = { - int(link["url"].split("viewPanel=")[1].split("&")[0]) - for p in panels - for link in p.get("fieldConfig", {}).get("defaults", {}).get("links", []) - if "viewPanel=" in link.get("url", "") -} -for missing in sorted(targets - set(ids)): - problems.append(f"link points at viewPanel={missing}, which no panel has") + targets = { + int(link["url"].split("viewPanel=")[1].split("&")[0]) + for p in panels + for link in p.get("fieldConfig", {}).get("defaults", {}).get("links", []) + if "viewPanel=" in link.get("url", "") + } + for missing in sorted(targets - set(ids)): + problems.append(f"link points at viewPanel={missing}, which no panel has") -# Panels are placed on a 24-column grid by hand in patch scripts; two panels -# claiming the same cells makes Grafana shuffle them unpredictably on load. -placed = [p for p in dash["panels"]] + # Panels are placed on a 24-column grid by hand in patch scripts; two panels + # claiming the same cells makes Grafana shuffle them unpredictably on load. + placed = [p for p in dash["panels"]] -def overlaps(a, b): - return not ( - a["x"] + a["w"] <= b["x"] - or b["x"] + b["w"] <= a["x"] - or a["y"] + a["h"] <= b["y"] - or b["y"] + b["h"] <= a["y"] - ) + def overlaps(a, b): + return not ( + a["x"] + a["w"] <= b["x"] + or b["x"] + b["w"] <= a["x"] + or a["y"] + a["h"] <= b["y"] + or b["y"] + b["h"] <= a["y"] + ) -for i, first in enumerate(placed): - for second in placed[i + 1 :]: - if overlaps(first["gridPos"], second["gridPos"]): - problems.append( - f"panels overlap on the grid: {first.get('title')!r} and {second.get('title')!r}" - ) + for i, first in enumerate(placed): + for second in placed[i + 1 :]: + if overlaps(first["gridPos"], second["gridPos"]): + problems.append( + f"panels overlap on the grid: {first.get('title')!r} and {second.get('title')!r}" + ) + + all_problems += [f"{path.name}: {x}" for x in problems] + print(f" {path.name}: {len(panels)} panels, {len(targets)} link targets") + +problems = all_problems for problem in problems: print(f" FAIL {problem}") if problems: sys.exit(1) -print( - f" ok: {len(panels)} panels, ids unique, {len(targets)} link targets resolve, " - f"{len(placed)} placed with no overlaps" -) +print(f" ok: {len(paths)} dashboard(s) valid") diff --git a/scripts/check-public-safe.sh b/scripts/check-public-safe.sh new file mode 100755 index 0000000..8cde784 --- /dev/null +++ b/scripts/check-public-safe.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Preflight before serving the board world-readable. +# +# Checks the two things that actually leak: the DATA (does any private repo +# appear?) and the ACCESS MODE (can a visitor ask for something other than this +# dashboard?). Run it against the stack exactly as it will be exposed. +set -uo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +GRAFANA=http://localhost:3000 +COLLECTOR=http://localhost:9109 +fails=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fails=$((fails + 1)); } +warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; } + +metrics=$(curl -s --max-time 10 "$COLLECTOR/metrics") +repos=$(echo "$metrics" | grep '^jq_repo_info' | sed 's/.*repo="\([^"]*\)".*/\1/' | sort -u) + +# 1. Every repo the collector exports must actually be public on GitHub. This is +# checked against GitHub rather than against our own visibility label, so a +# stale or mislabelled snapshot cannot pass. +private=0 +while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + vis=$(gh api "repos/$repo" --jq '.visibility' 2>/dev/null) + if [[ "$vis" != "public" ]]; then + bad "exported repo is not public on GitHub: $repo ($vis)" + private=$((private + 1)) + fi +done <<< "$repos" +[[ "$private" -eq 0 ]] && ok "all $(wc -l <<< "$repos" | tr -d ' ') exported repos are public on GitHub" + +# 2. No unauthenticated arbitrary query. +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$GRAFANA/api/ds/query" \ + -H 'Content-Type: application/json' \ + -d '{"queries":[{"refId":"A","datasource":{"type":"prometheus","uid":"jq-prometheus"},"expr":"up","instant":true}]}') +if [[ "$code" == "401" || "$code" == "403" ]]; then + ok "anonymous /api/ds/query refused (HTTP $code)" +else + bad "anonymous /api/ds/query returned HTTP $code - a visitor can run any PromQL" +fi + +# 3. No unauthenticated datasource proxy. This is the one that exposes the raw +# label index, where purged private repo names linger until head compaction. +code=$(curl -s -o /dev/null -w '%{http_code}' \ + "$GRAFANA/api/datasources/proxy/uid/jq-prometheus/api/v1/label/repo/values") +if [[ "$code" == "401" || "$code" == "403" ]]; then + ok "anonymous datasource proxy refused (HTTP $code)" +else + bad "anonymous datasource proxy returned HTTP $code - the label index is readable" +fi + +# 4. Prometheus and the collector must never be published themselves. +for name in jq-prometheus jq-collector; do + binding=$(docker inspect "$name" --format '{{json .NetworkSettings.Ports}}' 2>/dev/null) + if grep -q '"0.0.0.0"' <<< "$binding"; then + bad "$name publishes a port on 0.0.0.0 - bind it to 127.0.0.1" + else + ok "$name is not published beyond loopback" + fi +done + +# 5. The loaded gun: a live public link while the collector is gathering private +# repos. Each is fine alone; together the public link serves private data the +# moment anyone reaches this Grafana. +public_only=$(grep -E '^JQ_PUBLIC_ONLY=' .env 2>/dev/null | cut -d= -f2 | tr '[:upper:]' '[:lower:]') +for uid in jq-fleet jq-fleet-public; do + enabled=$(curl -s "http://admin:admin@localhost:3000/api/dashboards/uid/$uid/public-dashboards" 2>/dev/null | + python3 -c 'import json,sys +try: print(str(json.load(sys.stdin).get("isEnabled", False)).lower()) +except Exception: print("false")') + if [[ "$enabled" == "true" && "$public_only" != "true" ]]; then + bad "$uid has a LIVE public link while JQ_PUBLIC_ONLY is '$public_only' - it would serve private repos" + elif [[ "$enabled" == "true" ]]; then + ok "$uid public link is live, and JQ_PUBLIC_ONLY=true" + fi +done +[[ "$public_only" == "true" ]] && ok "collector is restricted to public repos" \ + || warn "JQ_PUBLIC_ONLY is '$public_only' - fine for local use, but no public link may be live" + +# 6. Informational: purged names can still sit in the index for a couple of +# hours. Harmless behind a public dashboard, fatal behind an open Grafana. +idx=$(curl -s "http://localhost:9090/api/v1/label/repo/values" 2>/dev/null | + python3 -c 'import json,sys;print(" ".join(json.load(sys.stdin)["data"]))' 2>/dev/null) +stale=0 +while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + grep -qw -- "$repo" <<< "$idx" || true +done <<< "$repos" +for name in $idx; do + grep -qx -- "$name" <<< "$repos" || stale=$((stale + 1)) +done +[[ "$stale" -gt 0 ]] && warn "$stale name(s) linger in Prometheus's label index (purged data, clears at head compaction). Safe behind a public dashboard; NOT safe behind an open Grafana." + +echo +if [[ "$fails" -gt 0 ]]; then + echo "NOT SAFE TO EXPOSE - $fails check(s) failed" >&2 + exit 1 +fi +echo "Safe to expose: publish via Share -> Public dashboard, and share only that link." diff --git a/scripts/make-public-dashboard.py b/scripts/make-public-dashboard.py new file mode 100755 index 0000000..2b5cff0 --- /dev/null +++ b/scripts/make-public-dashboard.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Generate the public copy of the fleet board from the private one. + +Grafana's public dashboards do not resolve template variables, so every panel +filtered on `{repo=~"$repo"}` renders "No data" behind a public link. This +strips the variable and its selectors, leaving queries that mean the same thing +because the collector is already restricted to public repos by JQ_PUBLIC_ONLY. + +Run after changing fleet.json; the output is provisioned like any other file. +""" + +import json +import pathlib +import re + +HERE = pathlib.Path(__file__).resolve().parent.parent +SRC = HERE / "grafana/dashboards/fleet.json" +DST = HERE / "grafana/dashboards/fleet-public.json" + +dash = json.loads(SRC.read_text()) +dash["uid"] = "jq-fleet-public" +dash["title"] = "Jebel-Quant Fleet (public)" +dash["description"] = ( + "Public copy of the fleet board. Covers public repos only - the collector " + "runs with JQ_PUBLIC_ONLY, so private repos are never gathered." +) +dash["templating"] = {"list": []} +dash["tags"] = sorted(set(dash.get("tags", [])) | {"public"}) + +raw = json.dumps(dash) +# `{repo=~"$repo", conclusion!~...}` -> `{conclusion!~...}`; a lone selector goes entirely. +raw = raw.replace('{repo=~\\"$repo\\", ', "{") +raw = raw.replace('{repo=~\\"$repo\\"}', "") +# Drill-down links must stay inside the public dashboard. +raw = raw.replace("/d/jq-fleet/jebel-quant-fleet?", "/d/jq-fleet-public/jebel-quant-fleet-public?") + +leftover = re.findall(r"\$repo", raw) +if leftover: + raise SystemExit(f"{len(leftover)} unresolved $repo references remain - fix the stripper") + +DST.write_text(json.dumps(json.loads(raw), indent=2) + "\n") +print(f"wrote {DST.relative_to(HERE)}")