The RelOps Fleet Dashboard β one place to see everything, fix anything, and lose no more sleep over mystery workers.
Hangar pulls data from Taskcluster, SimpleMDM, Puppet, and Google Sheets and stitches it into a single live view of your entire test infrastructure. Pool health, hardware generations, task failures, quarantined machines, missing workers β all in one dark-themed dashboard. No more tab soup.
Currently tracking Mozilla's CI fleet. Built to grow with the rest of the infrastructure.
π οΈ Stack
| Layer | Technology |
|---|---|
| Frontend | React 19 + TypeScript, Vite, Tailwind CSS, Recharts, TanStack Table |
| Backend | FastAPI (Python 3.11), SQLAlchemy 2, APScheduler |
| Database | PostgreSQL 16 |
| Infrastructure | GCP (Cloud Run, Cloud SQL, Cloud Load Balancing, IAP, Cloud Build) |
π Production
| Resource | Value |
|---|---|
| URL | https://hangar.relops.mozilla.com |
| GCP Project | relops-dashboard |
| Cloud Run | hangar (us-central1) |
| Auth | Google IAP β @mozilla.com accounts only, no GCP access required |
| CI/CD | Cloud Build triggers on push to main |
π Quick Start (local dev)
# 1. Configure environment cp .env.example .env # edit .env β see Environment Variables below # 2. Start the database docker compose up -d db # 3. Build frontend cd frontend && npm install && npm run build && cd .. # 4. Start backend (serves built frontend at /) docker compose up backend
Open http://localhost:8000.
Frontend dev (hot reload)
cd frontend npm install npm run dev # http://localhost:5173 β proxies API to :8000
Backend dev (no Docker)
cd backend pip install -r requirements.txt export DATABASE_URL=postgresql://relops:relops@localhost:5432/relops uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 # requires: docker compose up -d db
π Environment Variables
All variables are optional unless marked required.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgresql://relops:relops@localhost:5432/relops |
Required. PostgreSQL DSN |
SIMPLEMDM_API_KEY |
β | SimpleMDM REST API key |
TC_ROOT_URL |
https://firefox-ci-tc.services.mozilla.com |
Taskcluster root URL |
TC_CLIENT_ID |
β | TC client ID (public pools work unauthenticated) |
TC_ACCESS_TOKEN |
β | TC access token |
GOOGLE_SHEETS_ID |
β | Master inventory spreadsheet ID |
GOOGLE_EXPORT_SHEET_ID |
β | Export/dashboard spreadsheet ID |
GOOGLE_CREDENTIALS_JSON |
β | Path to service account JSON |
PUPPET_REPO_URL |
https://github.com/mozilla-platform-ops/ronin_puppet |
Puppet repo |
PUPPET_REPO_PATH |
/tmp/ronin_puppet |
Local clone location |
SYNC_INTERVAL_TC |
300 |
Taskcluster sync interval (seconds) |
SYNC_INTERVAL_SIMPLEMDM |
900 |
SimpleMDM sync interval |
SYNC_INTERVAL_SHEETS |
1800 |
Google Sheets sync interval |
SYNC_INTERVAL_PUPPET |
3600 |
Puppet sync interval |
TC_MISSING_THRESHOLD_HOURS |
24 |
Hours before raising a missing_from_tc alert |
ποΈ Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β React SPA (Vite) β
β Overview Β· Workers Β· Alerts Β· Pools Β· Tart VMs β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
β REST
ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ
β FastAPI backend β
β /api/workers /api/fleet /api/alerts /api/tart-healthβ
β β
β APScheduler βββ sync/taskcluster.py (5 min) β
β βββ sync/simplemdm.py (15 min) β
β βββ sync/google_sheets.py(30 min) β
β βββ sync/puppet.py (60 min) β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
β SQLAlchemy
ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ
β PostgreSQL 16 β
β workers Β· alerts Β· sync_logs Β· failure_events β
β tart_slot_health (pushed by the on-network agent) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
GCP architecture
User (mozilla.com Google account)
β
βΌ
Global Load Balancer (34.54.129.77)
β Cloud Armor (OWASP rules + rate limiting)
β Identity-Aware Proxy (IAP) β google auth gate
βΌ
Cloud Run β hangar (us-central1, min 1 instance)
β INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER (direct URL blocked)
βΌ
Cloud SQL β Postgres 16 (private IP via VPC connector)
Data sources
| Source | What it provides | Sync |
|---|---|---|
| Taskcluster | Worker state, quarantine status, last active, latest task | GraphQL + REST |
| SimpleMDM | MDM enrollment, OS version, serial number, custom attributes | REST (paginated) |
| Puppet | Worker role, pool assignment | Git clone of ronin_puppet |
| Google Sheets | Canonical state (production / loaner / defective / spare / staging), notes | Sheets API v4 |
πΊ Pages
πΊοΈ Overview
Your morning briefing. Fleet-wide summary stats, sync health, top-10 failing machines and tests over the last 7 days, generation breakdown chart, workers-by-pool bar chart.
π₯οΈ Workers
The full roster. Filterable and sortable across generation, state, pool, and MDM/TC status. Search by hostname or serial number. Up to 2,000 rows.
π Worker Detail
Everything Hangar knows about a single worker β Puppet role, sheet state, MDM enrollment, TC history. Edit notes and jump out to Taskcluster.
π¨ Alerts
The stuff that needs your attention. Types: missing_from_tc, quarantined, mdm_unenrolled, pool_mismatch. Add notes, acknowledge, resolve.
π Pool Health
Per-pool health scores, staleness breakdowns (active <24 h / 1β7 d / 7β30 d / 30 d+ / never seen), and job source distribution. Branch-override visibility (counts and which pools/branches are pinned).
π¦ Consolidation
Side-by-side hardware generation comparison β state breakdowns, inactive machines, and retirement candidates.
π²οΈ Tart VMs
Per-slot health for the tart VM hosts behind gecko-t-osx-1500-m-vms (macOS β Tart VMs in the sidebar).
The unit is the slot, not the host: each host runs two VMs that fail independently, and Taskcluster's view β which is what Pool Health shows β can't see inside one. In July 2026 five of 26 slots were out of production for weeks because three guests were crash-rebooting every ~84 s while tart run on the host stayed up 11+ days, so every host-level signal read green.
Columns cover the worker id, TC registration, tart run uptime, whether credentials are host-injected or image-baked, and guest disk headroom; rows expand to the problem list plus VM state, clock skew, configured-vs-actual identity, puppet SHA and cert expiry.
Two behaviours worth knowing:
- Stale reads as
unknown, neverok. Rows older than 30 minutes are reported stale β a wedged collector must not leave the fleet looking green. - Cert expiry is judged against the cert's own lifetime, not a fixed number of days. These hosts hold 168 h certs, so "expires within 7 days" is the steady state; a warning means the renew daemon has actually missed its window.
Data arrives from an on-network agent, not from Hangar β see Tart VM slot health below.
π Reprovision β one-click EACS β prod
The headline capability: wipe, re-enroll, and fully re-provision a Mac worker end-to-end from a button in the dashboard β no SSH, no console, no pasted secrets.
On a worker's detail page, authorized operators (IAP allowlist) get a Reprovision cockpit: live readiness (quarantined? idle? M4/EACS-supported?), an Execute button behind a confirm, and a live Apple-rainbow terminal timeline that streams every phase of the run.
How it reaches the datacenter safely. Hangar runs on Cloud Run and can't SSH into MDC1 β so it never does:
- Execute enqueues a
ReprovisionJob(one open job per host). - An on-network runner β Puppet-managed on an MDC1 mini β polls Hangar over mTLS (outbound only, no inbound to the datacenter), claims the job, and runs the
reprovisionCLI. - Each phase streams back as a job event and renders live: quarantine β drain β EACS wipe β DEP re-enroll β SecureToken mint β Bootstrap Token β self-provision (mTLS vault Β· puppet Β· Taskcluster).
The runner holds all SSH/admin creds; Hangar holds none. A server-side reaper closes any job whose runner vanishes, so a lost run never wedges a host.
Proven in prod: a Hangar-enqueued job reprovisioned
macmini-m4-80end-to-end, driven by the Puppet-managed runner onmacmini-m4-81, streaming the whole flow into the cockpit.
π²οΈ Tart VM slot health
Same architectural constraint as Reprovision: Cloud Run can't reach MDC1, so Hangar never SSHes a tart host. An on-network agent does the collecting and pushes results in.
macmini-m4-81 (Puppet-managed) Hangar
com.mozilla.hangar-tart-health-agent
every 600s:
ssh admin@<13 tart hosts> ββcollectβββΆ
POST /api/tart-health/agent/push ββββββΆ require_runner (mTLS client cert)
β
evaluate() β ok/warn/crit/unknown
β
browser βββΆ GET /api/tart-health ββββββββββΆ require_access (IAP)
The agent is hangar-tart-health-agent in relops-bootstrap, the third daemon on the reprovision runner host, reusing its venv, env file and mTLS cert.
Severity is derived server-side, in evaluate(), not by the agent. The agent is a dumb collector, so thresholds can be retuned by deploying Hangar alone β nothing on-network changes β and the API and UI can't disagree about what crit means.
Routing is load-bearing
Only /api/tart-health/agent/* is routed to the non-IAP mTLS backend (terraform/lb_runner.tf). GET /api/tart-health deliberately stays on the IAP frontend.
require_runner does no per-path scoping, so that URL map is the only thing keeping the read rollup behind human auth. Routing a broad /api/tart-health/* to the runner backend as a convenience would expose fleet data to anything inside runner_source_cidrs holding a valid cert, with no Google identity attached.
Diagnostic, if a push ever stops landing: on the runner hostname a path that reaches the right backend returns 403 (Cloud Armor source deny from anywhere but MDC1). A 301 to the IAP domain means the path isn't in the allowlist at all.
Known gap
tart_health_guests is currently false, so guest-level checks β disk headroom, clock skew, worker identity β are not collected and those columns are blank. Those are the checks that catch a crash-looping guest inside a healthy host, i.e. the July 2026 failure mode. Until it's enabled, treat a green Tart VMs page as "the hosts look fine", not "the fleet is fine".
ποΈ Database Schema
workers β one row per hostname, columns from all four sources
alerts β active/resolved per-worker alerts
sync_logs β audit trail for each sync run (source, duration, records updated, errors)
failure_events β TC task failures indexed by hostname and task name
tart_slot_health β latest health row per tart VM slot (PK hostname+slot), pushed by the agent
Worker state precedence: sheet_state (if set) β inferred from TC/Puppet membership β unknown
Health score: fraction of production workers that are MDM-enrolled, not quarantined, and active within 24 hours.
π‘ API Reference
GET /api/workers list + filter + search + sort
GET /api/workers/{hostname} full worker record
PATCH /api/workers/{hostname}/notes update dashboard notes
GET /api/fleet/summary dashboard stats
GET /api/fleet/pools per-pool health
GET /api/fleet/pending-counts TC pending tasks per pool
GET /api/fleet/pool-sources running task project breakdown
GET /api/fleet/failures?days=7 top failing machines + tests
GET /api/fleet/consolidation hardware generation analysis
GET /api/alerts list (filter: type, active_only)
PATCH /api/alerts/{id}/acknowledge
PATCH /api/alerts/{id}/resolve
GET /api/prs/ronin ronin_puppet PR queue + voting
POST /api/prs/ronin/{n}/upvote
POST /api/prs/ronin/{n}/downvote
GET /api/tart-health tart VM slot rollup, worst-first (IAP)
GET /api/tart-health/{hostname} one host's slots (IAP)
POST /api/tart-health/agent/push on-network agent push (mTLS runner cert)
POST /api/sync/run trigger manual sync
GET /api/health liveness check
π Project Structure
hangar/
βββ backend/
β βββ app/
β β βββ main.py FastAPI app, lifespan, route registration
β β βββ config.py Pydantic Settings β all env vars
β β βββ database.py SQLAlchemy engine + table init
β β βββ models.py ORM models
β β βββ api/
β β β βββ workers.py
β β β βββ fleet.py
β β β βββ alerts.py
β β β βββ tart_health.py tart slot rollup + agent push, severity logic
β β β βββ prs.py ronin_puppet PR voting
β β βββ sync/
β β βββ scheduler.py APScheduler job registration
β β βββ taskcluster.py GraphQL + REST sync, alert generation
β β βββ simplemdm.py Paginated REST sync + custom attributes
β β βββ puppet.py Git clone + inventory.d parse
β β βββ google_sheets.py Sheets API v4 read
β βββ Dockerfile
β βββ requirements.txt
βββ frontend/
β βββ src/
β β βββ App.tsx React Router setup
β β βββ api.ts Typed API client + all TS interfaces
β β βββ components/
β β β βββ Layout.tsx App shell β sidebar nav (collapses to hamburger drawer on mobile)
β β β βββ CommandPalette.tsx βK quick search
β β β βββ KeyboardShortcuts.tsx
β β βββ pages/
β β βββ Overview.tsx
β β βββ Workers.tsx
β β βββ WorkerDetail.tsx
β β βββ Alerts.tsx
β β βββ Pools.tsx
β β βββ TartVMs.tsx per-slot tart VM health
β β βββ Consolidation.tsx
β βββ vite.config.ts Dev proxy β :8000
β βββ package.json
βββ terraform/ All GCP infrastructure
β βββ main.tf Provider, APIs, VPC, VPC connector
β βββ run.tf Cloud Run service
β βββ lb.tf Load balancer, IAP, Cloud Armor, SSL cert
β βββ lb_runner.tf mTLS frontend β runner/agent path allowlist
β βββ iam.tf Service accounts, IAM bindings, IAP access
β βββ sql.tf Cloud SQL Postgres 16
β βββ secrets.tf Secret Manager secrets
β βββ variables.tf All configurable vars
β βββ terraform.tfvars Local var overrides (gitignored)
βββ cloudbuild.yaml CI/CD β triggers on push to main
βββ docker-compose.yml Local dev (postgres + backend)
βββ .env.example Template for local dev
β¨οΈ Keyboard Shortcuts
| Shortcut | Action |
|---|---|
β K |
Open command palette (jump to any worker) |
β / |
Open keyboard shortcuts help |
Esc |
Close any modal |