Files
ghtracker/README.md
T
lukaszraczylo 9446caaf1b feat(ui): order projects by their newest problem
Projects now sort by the newest alert they hold, whether a failing workflow, a waiting pull request or a stale issue. Projects with equal ages keep the server order.
2026-10-03 00:14:23 +01:00

189 lines
11 KiB
Markdown

# ghtracker
Dashboard and Prometheus metrics for a list of GitHub repositories. The program authenticates as a GitHub App and refreshes every 6 hours by default.
## Install
Each release publishes binaries for Linux, macOS and Windows on the [releases page](https://github.com/lukaszraczylo/ghtracker/releases), and a multi-architecture container image:
```sh
docker pull ghcr.io/lukaszraczylo/ghtracker:latest
```
Release archives and the image checksums are signed with cosign.
## What it shows
The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first:
- Failing workflows, including scheduled and other background ones, with a link to the run.
- Pull requests that wait too long or fail their checks, with links and age.
- Stale issues, as one line per project.
- Projects that failed to refresh, or loaded only in part.
A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Healthy items do not appear anywhere else.
### Search and filters
- Type in the search box, or press `/`, to match projects, versions, item titles and details. Every word must match. Press `Esc` to clear.
- Filter by severity, by kind (workflows, pull requests, issues, data problems) and by owner. Each chip shows how many items it would leave, given the other filters, and chips that would match nothing are disabled.
- Select a segment of the health strip or a project name to focus one project.
- The URL holds the filters (`?q=bump&kind=pr&owner=lukaszraczylo`), so you can bookmark or share a filtered view.
## Setup
1. Create a GitHub App with read-only repository permissions: Metadata, Issues, Pull requests, Actions, Checks, Commit statuses and Contents.
2. Install the App on the repositories you want to observe.
3. Generate a private key for the App.
4. Copy `config.example.yaml` to `config.yaml`, then set `github.app_id` and the key path. You can set `GHTRACKER_PRIVATE_KEY` to the PEM contents instead of a file.
5. Run `make build`, then `./ghtracker -config config.yaml`, and open http://localhost:8080.
## Alert rules
| Condition | Severity |
| ---------------------------------------------------------------------------------------- | -------- |
| Latest completed default-branch run of a workflow failed (includes background workflows) | crit |
| Non-draft PR open longer than `pr_crit_after` | crit |
| Repo refresh failed (the dashboard keeps the last good data) | crit |
| Non-draft PR open longer than `pr_warn_after` | warn |
| PR checks failing | warn |
| Workflow queued or running longer than `workflow_stuck_after` | warn |
| Open issues without activity for `issue_stale_after` (one alert per repo) | warn |
| A section failed to load, for example a missing App permission | warn |
| Data older than `data_stale_after` | warn |
Alerts are evaluated when you read them, so PR ages keep advancing between refreshes. Set a threshold to a negative value to disable it. Draft PRs and archived repos produce no work alerts.
## Endpoints
- `/` dashboard.
- `/metrics` Prometheus metrics.
- `/api/state` full state as JSON.
- `/healthz` returns 503 until the first refresh completes.
- `POST /refresh` runs an extra refresh and backs the Refresh button. It is on by default, limited to once per 5 minutes, and refuses cross-site requests. Set `manual_refresh: false` to disable it.
- `POST /refresh?repo=owner/name` refreshes only that repository and answers when it finishes. An empty `repo` value runs the full refresh above.
- The repository must be in the configured list, or the answer is 404 with a JSON `{"error": ...}` body. The match ignores case.
- On success the answer is 200 with the same JSON as `/api/state`, plus a `repo` field. Only that repository changes. The last scan time and the schedule do not move.
- Each repository has its own cooldown of 60 seconds. Inside it, the answer is 429 with a `Retry-After` header. A repository refresh does not use the 5-minute budget of the full refresh, and the reverse also holds.
- While a full refresh or a refresh of the same repository runs, the answer is 409. The cooldown does not start, so you can retry at once.
- The same cross-site refusal and `manual_refresh: false` switch apply. The GitHub rate-limit protections below apply too.
The dashboard shows a refresh button beside each project when `manual_refresh` is on.
## Metrics
All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time.
| Metric | Labels |
| -------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo |
| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo |
| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo |
| `pr_age_seconds`, `pr_checks_failing` | repo, number |
| `workflow_failing`, `workflow_running`, `workflow_last_run_timestamp_seconds` | repo, workflow, background |
| `release_info`, `release_published_timestamp_seconds` | repo, version, url |
| `alerts` | severity |
| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none |
Example alert rules:
```yaml
- alert: GhtrackerWorkflowFailing
expr: ghtracker_workflow_failing == 1
for: 10m
- alert: GhtrackerPRWaiting
expr: ghtracker_pr_age_seconds > 3 * 86400
- alert: GhtrackerRepoDown
expr: ghtracker_repo_up == 0
for: 30m
- alert: GhtrackerStale
expr: time() - ghtracker_last_refresh_timestamp_seconds > 13 * 3600
- alert: GhtrackerRateLimitLow
expr: ghtracker_github_rate_limit_remaining < 500
```
## GitHub rate limits
The program stays well below the limits of an installation token (at least 5,000 requests per hour):
- One refresh costs about 6 requests per repo, plus 2 per open PR and 1 per active workflow. The default interval is 6 hours.
- Conditional requests with `ETag` make unchanged data free: a 304 reply does not count against the primary limit.
- Concurrency defaults to 2 repos at a time. Pagination stops at `max_items`.
- Requests stop while fewer than 100 remain in the window, until the window resets.
- On a primary or secondary limit response, the client honours `Retry-After` or the reset time, and the current refresh stops. It does not retry. Repos keep their last good data.
- Transient 5xx errors retry twice with a short back-off. Other errors do not retry.
- `refresh_interval` has a one-minute minimum.
## Limits of the workflow view
The program lists default-branch runs only. Runs of a workflow that never ran on the default branch do not appear. Pull request runs appear as PR checks.
## Docker
Run the published image, or build your own with the commands below.
```sh
docker run --rm -p 8080:8080 \
-v "$PWD/config.yaml:/config/config.yaml:ro" \
-e GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
ghcr.io/lukaszraczylo/ghtracker:latest
```
To build the image yourself:
```sh
docker build -t ghtracker .
docker run --rm -p 8080:8080 \
-v "$PWD/config.yaml:/config/config.yaml:ro" \
-e GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
ghtracker
```
The image is one static binary, with the UI embedded, on `distroless/static:nonroot`. The config must set `listen: ":8080"` so the port is reachable from outside the container. Build and push the image yourself, then set it in `deploy/kubernetes/deployment.yaml`.
## Kubernetes
`deploy/kubernetes/` holds a Kustomize base:
| File | Purpose |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `externalsecret.yaml` | Reads the App private key from your secret store through an External Secrets `ClusterSecretStore`. |
| `configmap.yaml` | The config file, with the App ID, installation ID and repo list. |
| `deployment.yaml` | One replica, non-root, read-only root filesystem, `GHTRACKER_PRIVATE_KEY` from the secret. |
| `service.yaml`, `ingressroute.yaml` | Service on port 80 and a Traefik route. Replace the host. |
| `servicemonitor.yaml`, `prometheusrule.yaml` | Scrape every 5 minutes and five starter alerts. |
```sh
kustomize build deploy/kubernetes | kubectl diff -f -
```
Commit the manifests to the GitOps repo and let ArgoCD sync them. Do not use `kubectl apply`.
Keep the replica count at 1. Each replica refreshes on its own and spends GitHub API budget. The dashboard has no authentication, so keep the route on an internal entrypoint or add an auth middleware.
## Frontend
The UI is a Vue 3 single-page app in `src/`: Vite, Pinia, Tailwind CSS 4, shadcn-vue components and Font Awesome icons. `pnpm build` writes it to `dist/`, and the Go binary embeds that directory with `go:embed`, so the result is one file.
The server returns `503 UI is not built` at `/` until you run `pnpm build`, because `go build` embeds whatever `dist/` holds. `make build` runs both steps.
The UI reads one JSON document from `/api/state` every 60 seconds. The Go side evaluates alert rules, and the UI only groups and displays them. Ages advance in the browser between polls.
## Releases
Every push to `main` runs the shared release workflow from [`lukaszraczylo/shared-actions`](https://github.com/lukaszraczylo/shared-actions). It runs the tests, calculates the version from the commit messages with `semver-generator`, then runs GoReleaser. The result is a GitHub release with binaries and checksums, and a container image on `ghcr.io`.
`semver.yaml` sets the rules: `fix`, `perf`, `refactor` and other maintenance types bump the patch number, `feat` bumps the minor number, and a `Semver-Major:` trailer line is the only way to start a major release.
`workflow-prepare.sh` builds the UI before the Go build, because the binary embeds `dist/`.
## Development
```sh
make dev # Go server on :8080 plus Vite with hot reload on :5173
make test # vitest and go test -race
make lint # eslint, vue-tsc and golangci-lint
```
The Dockerfile builds the UI in a Node stage, then builds the Go binary with the result.