From ddc4a3a6dc7fc895ad3a728bfdf8cf4645f2ffc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81ris=20Pop=C4=93ns?= Date: Mon, 5 Oct 2026 21:44:14 +0300 Subject: [PATCH] feat: link the dashboard's PR and Dependabot panels through to GitHub Add github_org_info{org}, so the dashboard can build github.com links without a hand-set variable. Open PRs by Repo bars link to each repo's pull requests, a new Dependabot Alerts by Repo table links each repo to its alerts, and the org-wide panels link to the org's PR search and Dependabot overview. Also: the Dependabot severity panel says "No open alerts" instead of No data, and the bar gauges use fixed sizes so a single bar no longer blows up and clips the repo name. --- README.md | 3 +- .../github-actions-runner-exporter.json | 167 +++++++++++++++++- internal/collector/collector.go | 11 +- main.go | 2 +- 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cc35197..89afa6a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ scrape. | `github_runner_up` | `runner`, `os` | 1 if the runner is registered and online, 0 if offline | | `github_runner_busy` | `runner`, `os` | 1 if the runner is currently executing a job, 0 if idle | | `github_org_up` | | 1 if the last org/repo stats poll succeeded, 0 if a stale cache is being served or the first poll is still running (about a minute after startup at ~25 repos) | +| `github_org_info` | `org` | Always 1; the org this exporter watches. The dashboard reads it to link to github.com | | `github_org_repos_total` | `visibility` | Number of non-archived repos, by `public`/`private` | | `github_rate_limit_remaining` | | Remaining core API budget in the scarcest active window: the one that runs out first | | `github_rate_limit_limit` | | Total core API budget of that window | @@ -153,7 +154,7 @@ images to GHCR with SLSA provenance attestation on push to `main`/tags. [`dashboards/github-actions-runner-exporter.json`](dashboards/github-actions-runner-exporter.json) covers every metric above: - runners online, offline and busy, with per-runner state timelines; - both exporter polls' health, so a stale cache is visible; -- CI health per repo and workflow, open PRs, Dependabot alerts by severity, and the API rate limit; +- CI health per repo and workflow, open PRs, Dependabot alerts by severity and by repo, and the API rate limit; the PR and Dependabot panels link through to the lists on github.com; - CI history: average and p95 run time per job, queue time per runner pool and per runner, and workflow runs by conclusion. Import it in Grafana (Dashboards → New → Import) and pick your Prometheus data source. diff --git a/dashboards/github-actions-runner-exporter.json b/dashboards/github-actions-runner-exporter.json index 7afe471..c5b628c 100644 --- a/dashboards/github-actions-runner-exporter.json +++ b/dashboards/github-actions-runner-exporter.json @@ -8,7 +8,7 @@ "prometheus" ], "schemaVersion": 39, - "version": 6, + "version": 7, "editable": true, "time": { "from": "now-24h", @@ -35,6 +35,23 @@ "hide": 0, "refresh": 1 }, + { + "name": "org", + "label": "GitHub org", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "query": { + "query": "label_values(github_org_info, org)", + "refId": "org" + }, + "definition": "label_values(github_org_info, org)", + "refresh": 1, + "hide": 2, + "current": {} + }, { "name": "repo", "label": "Repo", @@ -661,6 +678,13 @@ "range": false, "refId": "A" } + ], + "links": [ + { + "title": "All open PRs in the org", + "url": "https://github.com/pulls?q=is%3Aopen+is%3Apr+org%3A${org}", + "targetBlank": true + } ] }, { @@ -669,7 +693,7 @@ "type": "bargauge", "gridPos": { "h": 8, - "w": 12, + "w": 8, "x": 0, "y": 18 }, @@ -697,12 +721,21 @@ "color": "red" } ] - } + }, + "noValue": "No open alerts" } }, "options": { "orientation": "horizontal", - "displayMode": "gradient" + "displayMode": "gradient", + "sizing": "manual", + "minVizHeight": 16, + "maxVizHeight": 28, + "text": { + "titleSize": 14, + "valueSize": 16 + }, + "namePlacement": "top" }, "targets": [ { @@ -712,16 +745,108 @@ "range": false, "refId": "A" } + ], + "description": "Open Dependabot alerts across the org. Click the title link for the list on GitHub.", + "links": [ + { + "title": "Dependabot alerts on GitHub", + "url": "https://github.com/orgs/${org}/security/alerts/dependabot", + "targetBlank": true + } ] }, + { + "id": 25, + "title": "Dependabot Alerts by Repo", + "type": "table", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "description": "Open alerts per repo and severity. Click a repo to open its Dependabot alerts on GitHub.", + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "targets": [ + { + "expr": "max by (repo, severity) (github_repo_dependabot_alerts_open)", + "format": "table", + "instant": true, + "range": false, + "refId": "A" + } + ], + "transformations": [ + { + "id": "groupingToMatrix", + "options": { + "columnField": "severity", + "rowField": "repo", + "valueField": "Value", + "emptyValue": "zero" + } + }, + { + "id": "organize", + "options": { + "renameByName": { + "repo\\severity": "repo" + }, + "indexByName": { + "repo\\severity": 0, + "critical": 1, + "high": 2, + "medium": 3, + "low": 4 + } + } + } + ], + "fieldConfig": { + "defaults": { + "noValue": "No open alerts", + "unit": "short", + "custom": { + "align": "auto" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "repo" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Dependabot alerts in ${__data.fields.repo}", + "url": "https://github.com/${org}/${__data.fields.repo}/security/dependabot", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm" + } + }, { "id": 15, "title": "Open PRs by Repo", "type": "bargauge", "gridPos": { "h": 8, - "w": 12, - "x": 12, + "w": 8, + "x": 16, "y": 18 }, "datasource": { @@ -741,12 +866,30 @@ "color": "blue" } ] - } + }, + "displayName": "${__field.labels.repo}", + "links": [ + { + "title": "Open PRs in ${__field.labels.repo}", + "url": "https://github.com/${org}/${__field.labels.repo}/pulls", + "targetBlank": true + } + ] } }, "options": { "orientation": "horizontal", - "displayMode": "gradient" + "displayMode": "gradient", + "namePlacement": "top", + "showUnfilled": true, + "valueMode": "color", + "sizing": "manual", + "minVizHeight": 16, + "maxVizHeight": 28, + "text": { + "titleSize": 14, + "valueSize": 16 + } }, "targets": [ { @@ -756,6 +899,14 @@ "range": false, "refId": "A" } + ], + "description": "Click a bar to open that repo's pull requests on GitHub.", + "links": [ + { + "title": "All open PRs in the org", + "url": "https://github.com/pulls?q=is%3Aopen+is%3Apr+org%3A${org}", + "targetBlank": true + } ] }, { diff --git a/internal/collector/collector.go b/internal/collector/collector.go index ce7398d..4d1bc71 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -16,6 +16,7 @@ import ( const namespace = "github" type Collector struct { + org string runnerFetcher *fetch.Fetcher[runners.Summary] orgFetcher *fetch.Fetcher[orgstats.Summary] rateLimitFn func() map[time.Time]github.RateLimit @@ -23,6 +24,7 @@ type Collector struct { runnerUp *prometheus.Desc runnerBusy *prometheus.Desc runnersUp *prometheus.Desc + orgInfo *prometheus.Desc orgUp *prometheus.Desc reposTotal *prometheus.Desc rateLimit *prometheus.Desc @@ -37,7 +39,7 @@ type Collector struct { } // New builds the collector. The intervals only feed HELP text; polling is the Fetchers' job. -func New(runnerFetcher *fetch.Fetcher[runners.Summary], orgFetcher *fetch.Fetcher[orgstats.Summary], rateLimits func() map[time.Time]github.RateLimit, runnerCacheTTL, orgCacheTTL time.Duration) *Collector { +func New(org string, runnerFetcher *fetch.Fetcher[runners.Summary], orgFetcher *fetch.Fetcher[orgstats.Summary], rateLimits func() map[time.Time]github.RateLimit, runnerCacheTTL, orgCacheTTL time.Duration) *Collector { runnerNote := fmt.Sprintf(" Polled every %s.", runnerCacheTTL) orgNote := fmt.Sprintf(" Polled every %s - not real-time by design, see internal/orgstats.", orgCacheTTL) desc := func(subsystem, name, help string, labels []string) *prometheus.Desc { @@ -45,6 +47,7 @@ func New(runnerFetcher *fetch.Fetcher[runners.Summary], orgFetcher *fetch.Fetche } return &Collector{ + org: org, runnerFetcher: runnerFetcher, orgFetcher: orgFetcher, rateLimitFn: rateLimits, @@ -58,6 +61,9 @@ func New(runnerFetcher *fetch.Fetcher[runners.Summary], orgFetcher *fetch.Fetche orgUp: desc("org", "up", "Whether the last scrape of the org/repo stats succeeded (1) or a stale cache is being served (0)."+orgNote, nil), + // lets dashboards build github.com links without a hand-set variable + orgInfo: desc("org", "info", + "Always 1; the GitHub org this exporter watches is in the org label.", []string{"org"}), reposTotal: desc("org", "repos_total", "Number of non-archived repos in the org."+orgNote, []string{"visibility"}), rateLimit: desc("rate_limit", "remaining", @@ -88,7 +94,7 @@ func New(runnerFetcher *fetch.Fetcher[runners.Summary], orgFetcher *fetch.Fetche func (c *Collector) Describe(ch chan<- *prometheus.Desc) { for _, d := range []*prometheus.Desc{ c.runnersUp, c.runnerUp, c.runnerBusy, - c.orgUp, c.reposTotal, c.rateLimit, c.rateLimitCap, c.rateWindow, + c.orgInfo, c.orgUp, c.reposTotal, c.rateLimit, c.rateLimitCap, c.rateWindow, c.repoOpenPRs, c.repoCILastRunConclusion, c.repoCILastRunAt, c.repoCIDuration, c.repoDependabot, } { ch <- d @@ -99,6 +105,7 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric) { c.collectRunners(ch) c.collectOrgStats(ch) c.collectRateLimits(ch) + ch <- prometheus.MustNewConstMetric(c.orgInfo, prometheus.GaugeValue, 1, c.org) } // collectRateLimits reports every active core window plus the scarcest one, diff --git a/main.go b/main.go index bab5f9a..87a01c0 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ func main() { }, cfg.OrgCacheTTL, cfg.OrgCacheMaxStale, log) registry := prometheus.NewRegistry() - registry.MustRegister(collector.New(runnerFetcher, orgFetcher, client.RateLimits, cfg.RunnerCacheTTL, cfg.OrgCacheTTL), feed) + registry.MustRegister(collector.New(cfg.GitHubOrg, runnerFetcher, orgFetcher, client.RateLimits, cfg.RunnerCacheTTL, cfg.OrgCacheTTL), feed) mux := http.NewServeMux() mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))