Hive
feat(server): Tuist Runners dashboard + billing — runners landing, workflows, jobs, settings hub
GitHub issue · Closed
Source
tuist/tuist #10848
Updated
Jun 24, 2026
Domains
Atlas
Compute
Summary
Builds out the Tuist Runners customer-facing surface — landing page → workflows list → workflow detail → jobs list → job detail — and the billing layer that anchors metered compute behind it. Also reorganizes the account navigation so the new pages have somewhere to live without each subsystem doubling the tab count.
96 files, +12.8k/-1.1k. Squashes a flat horizontal-tab account header into a sidebar + nested Settings hub, adds five new LiveViews, a new Postgres billing table, and a handful of ClickHouse migrations on the existing runner_jobs table.
Customer-facing surfaces
Navigation
- Account layout: leading sidebar matching the project layout (Projects, Runners, Members, Settings).
- Settings hub: Integrations, Billing, Authentication move under
/settings/*as a horizontal tab bar. Stripe success/manage redirects and the GitHub App post-install redirect updated to match.
Runners overview — /:account/runners
- Total jobs, p50/p90/p99 job duration, p50/p90/p99 workflow duration widgets with trend chips and a date-range picker.
- Recent jobs + Recent workflow runs cards (bar charts + tables), each row clickable to the relevant detail page.
- Page-level Platform (
macOS/Linux) + Repository dropdowns scope every widget.
Workflows list — /:account/runners/workflows
- Per-workflow rollups (one row per
(workflow_name, repository)): total jobs, success rate, avg duration, last run. - Column-header sort (
workflow/jobs/success_rate/avg_duration), workflow-name search, Repository / Platform dropdowns, pagination.
Workflow detail — /:account/runners/workflows/:owner/:repo/:workflow_name
- Scoped widgets (total jobs, failed jobs, p50/p90/p99 duration, queue time) and a jobs table for that workflow.
- Header carries the repo as a clickable GitHub link.
Jobs list — /:account/runners/jobs
- One row per workflow_job, status pill filter (queued/claimed/running/completed), Repository / Platform / Workflow / Job / Branch / Conclusion filter chips, free-text job-name search, column-header sort, pagination.
- Analytics widgets above the table: Job runs (Total / Passed / Failed dropdown), Cumulative compute minutes, p50/p90/p99 job duration, p50/p90/p99 queue time. Duration charts toggle between line and scatter; the scatter groups by status or platform via a Group-by dropdown.
- Charts switch from daily to hourly buckets for windows ≤ 36h.
- Running / Queued widgets live-update over PubSub on every state-transition INSERT.
Job detail — /:account/runners/runs/:workflow_run_id/jobs/:workflow_job_id
- URL hierarchy mirrors GitHub’s
/<owner>/<repo>/actions/runs/<run_id>/job/<job_id>so the two stay symmetric; mismatched run_id in the URL 404s the same way GitHub does. - Header card with status + GitHub deep-link; metadata grid (repository, workflow, job, branch, commit, fleet/platform, runner, run attempt, IDs).
- Timeline card: queue time / claim → start / runtime / total durations + relative + absolute timestamps per lifecycle event.
What’s behind the pages
Billing
- New Postgres
runner_sessionstable (one row per Pod we provisioned), keyed off the Pod’s wall-clock lifetime — the signal Namespace and Blacksmith bill against.runner_claimsis deleted on completion so can’t drive historical invoicing;runner_jobs(CH) tracks the GitHub-side workflow_job lifecycle which doesn’t match what we charge for. Tuist.Runners.Billing.compute_minutes/2rolls sessions up tototal_ms+ per-bucket series + trend-vs-previous-window, scoped by the same Repository / Workflow / Platform opts the widgets use.- Sessions are opened at claim-win (
Jobs.record_claimed/3→RunnerSessions.open/1) and closed by the runners-controller viaPOST /api/internal/runners/pods/stoppedwhen it observes the Pod’s container terminate — the close signal is K8s’scontainerStatuses[runner].state.terminated.finishedAt, not the completion webhook. pods/stoppedis gated by SA-principal check: TokenReview validates the bearer token, then we compare the returned(namespace, name)against the rendered runners-controller SA. Any other in-cluster SA gets 401.
Schema
runner_jobs.workflow_name String DEFAULT ''(added on this branch). PlainString, notLowCardinality— workflow_name is per-account free-form input and would outgrow LowCardinality’s sweet spot.runner_jobs.repo→repositoryrename to align with the rest of the codebase’srepositoryconvention (URL params, LiveView assigns, GitHub webhook payloads, OIDC claims).runner_jobs.{claimed_at,started_at,completed_at}switched from non-nullDateTime64with an epoch sentinel toNullable(DateTime64). “Not yet” is NULL instead of an epoch literal that occasionally leaked into the UI as1970-01-01. All read paths swaptoUnixTimestamp64Milli(?) > 0guards forisNotNull(?).runner_sessionscarries denormalizedrepository+workflow_nameso the Compute Minutes widget’s repository / workflow_name filters don’t have to join against ClickHouse.
Analytics queries
- Single
latest_jobs_subquery/2GROUP BY + argMax dedup pattern reused across every read path onrunner_jobs— same logical viewFROM … FINALwould produce without paying the per-read part-merge cost. count_workflows_for_account/2routes through the same subquery aslist_workflows_for_account/2so paged list / count never drift.workflows_durationrolls up viaGROUP BY workflow_run_id+HAVING countIf(status != 'completed') == 0— a run with one done + one queued job no longer contributes a truncated duration sample.minIf/maxIfaggregates withisNotNullguards exclude skipped/cancelled-and-never-started jobs from the timestamp bounds.
Web layer
Tuist.Utilities.DateFormattergains nil/missing handling — all fallback strings ("None","Unknown") routed throughdgettext("dashboard", …)so non-English viewers no longer see English fallbacks mixed into otherwise-localized tables.- Empty-state docs links on Runners / Workflows / Workflow detail / Jobs pages routed through
TuistWeb.Marketing.Localization.localized_href/1so a viewer on/ja/…lands on the Japanese docs.
Test plan
- Server lib tests:
mix test test/tuist/runners/ test/tuist_web/controllers/runner_pods_controller_test.exs— 87/87 pass - LiveView tests:
mix test test/tuist_web/live/runner_*_live_test.exs test/tuist_web/live/runners_live_test.exs— all pass - DateFormatter tests survive the localization swap:
mix test test/tuist_web/utilities/date_formatter_test.exs— pass (gettext returns the msgid when no translation exists) -
mix compile --warnings-as-errors+mix formatclean - Browser-verified locally across all five LiveViews with seeded fixtures: list / filter / column-sort / pagination / detail row click-through / Settings tab switching / sidebar highlight / line+scatter chart toggle / date-range picker / Group-by dropdown
- Staging deploy (run 26502770968) for end-to-end runners verification with the migrations applied
🤖 Generated with Claude Code