macOS · one command · 127.0.0.1
Launchd has no dashboard.This is the console for managing your own launchd services in the browser, and it exports Prometheus metrics.
One command installs it. The browser shows status, logs, and alerts.
| Label | PID | Exit | Runs | Actions | |
|---|---|---|---|---|---|
Example App3105 jobs | |||||
com.example.app.apiOurslisted | 730872h 14m | 0 | 28 | 🔔RestartLogs | |
com.example.app.webOursauto (path)↻ churn | 730916d 3h | 0 | 1207↻ | 🔔RestartLogs | |
com.example.app.collectorOursauto (path) | 7310418m | 0 | 21 | 🔔RestartLogs | |
com.example.worker.nightlyOurslisted | — | 1 | 3 | 🔔StartLogs | |
com.example.backup.weeklyOursauto (path)next · Sun 03:00 | — | 0 | 0 | 🔔StartLogs | |
Ops0012 jobs | |||||
com.example.worker.oldOurslisted⏹ retired 12d | — | — | 0 | EnableLogs | |
com.example.legacy.agentOursauto (path) | — | 0 | 0 | StartLogs | |
Click Ours, Other, or Noise. Then open /metrics and the Telegram alert.
Why
launchctl lists everything, so you can see nothing.
The stock tools answer "what is loaded", never "what did I deploy, and is it healthy". A user-domain filter is no help either — on a working Mac most of what looks like "your" jobs is Dropbox, Google's updater, Spotify, Tailscale and a browser helper or two. The one job that has restarted 1,207 times is buried in the middle of it.
Deployboard starts from an explicit inventory: an allowlist you control, plus path derivation — a job whose plist log path, working directory, program or arguments live inside your project tree is one of yours, automatically. Everything else is the system's business and stays one click away, never in the way.
- launchctl list — 545 rows, no idea which are yours
- A dev server that has silently restarted 1,207 times looks healthy
- A plist you disabled on purpose still reads as a failure
- No history: "is this normal?" has no answer
- You find out something died when you notice by accident
- Ours / Other / Noise — your deployments first, by default
- runs (restart counter) from launchctl print, flagged past a threshold
- disabled is a status, not an error — retired means retired
- Prometheus /metrics so Grafana can show "since when"
- Telegram alerts on transitions, per application, with a cooldown
Statuses
Seven values, and one of them means “I retired it”.
Most tools collapse to running / stopped / error, and then a plist you switched off months ago reads as a failure forever. Deployboard keeps the distinctions launchd actually exposes — and reads launchctl print-disabled so intent is part of the answer.
PID > 0 — launchd print agrees it is alive
PID = 0, clean exit, schedule defined, no recent log
PID = 0, clean exit, log mtime inside --recent-window
PID = 0, clean exit, no schedule, no recent log
Last exit status non-zero
launchctl print-disabled says disabled, no live pid — retired, not broken
plist file present, launchctl list does not return the label
What this fork adds
Upstream answers “what is loaded”. This answers “what did I deploy”.
Four additions, all of them built on launchctl's own output — no heuristics about which vendor a job belongs to.
launchctl print, not launchctl list.
launchctl list has no restart counter and no disabled flag. Deployboard reads launchctl print for runs and pid, and launchctl print-disabled for intent — so churn is visible and a deliberately retired job stops looking broken.
An inventory you decide on.
ours / other / noise, with provenance on every row (listed, auto (path), unclassified). The classify control writes back to config.json, which is hot-reloaded in ~2 seconds — no restart to teach it a new app.
Prometheus metrics for launchd.
deployboard_job_up, _runs, _last_exit_code, _disabled plus IPv4/IPv6 port probes. A dashboard can only tell you "down now"; a time series tells you "this started churning at 03:10".
Telegram alerts with per-app toggles.
Transitions only — error, offline, port unreachable, restart storm, and recovery — with a per-application switch, a cooldown, quiet hours, and the bot token held in the macOS Keychain, never in a file.
stdout + stderr per job, up to 10,000 lines, live-filtered.
exit code, program exists, program executable, plist owner, plist permissions, log path.
GET /api/events pushes the full job list every 5s.
start / stop / reload through launchctl, with a confirmation dialog.
the same surface the UI consumes, documented and curl-friendly.
one Go binary, frontend embedded via go:embed, no daemon, no telemetry.
Metrics
The only launchd exporter in this niche.
Point Prometheus at the same port the UI uses. Bounded label cardinality, honest omissions (no last-exit series for a job that never exited), probe series only for jobs that expose a port — and probes that try IPv6 ::1 too, because some dev servers bind there and nowhere else.
# HELP deployboard_job_up 1 when the launchd job is running.# TYPE deployboard_job_up gaugedeployboard_job_up{label="com.example.app.api",category="ours",group="Example App"} 1deployboard_job_runs{label="com.example.app.web",category="ours",group="Example App"} 1207deployboard_job_disabled{label="com.example.worker.old",category="ours"} 1deployboard_job_probe_reachable{label="com.example.app.api",port="8001"} 1deployboard_job_probe_latency_seconds{label="com.example.app.api",port="8001"} 0.003deployboard_jobs_total{category="ours"} 16deployboard_jobs_total{category="noise"} 528
# restart churn, per job, over 6hderiv(deployboard_job_runs{category="ours"}[6h]) * 3600# anything of yours that is not runningdeployboard_job_up{category="ours"} == 0
Scrape it from the same process that serves the UI — no exporter to deploy, no sidecar. Series are omitted rather than faked: a job that never exited gets no last_exit line, and a job with no port gets no probe line.
Alerts
It tells you, once, on Telegram.
Transition-based, so a flapping job cannot spam you: one message when it breaks, a rate-limited one if it keeps restarting, one when it comes back. Recovery is never suppressed by quiet hours. The bot token lives in the macOS Keychain and is written by the dashboard itself — never a file, never a log line, never returned to the page.
API
Everything the UI uses, also available to your scripts.
The web UI is one consumer of /api/jobs and /api/events. So is curl. Bind the server, point at the endpoints, parse the JSON.
Install
One command. One config file. One tab.
Three supported installs, same binary: Homebrew cask (hero command), curl install-release.sh for a LaunchAgent without Go, or bash install.sh from a clone. No sudo; scripts support --dry-run and --uninstall.
$ brew install --cask francistse/tap/deployboardHomebrew cask for the binary, or curl install-release.sh / bash install.sh for a LaunchAgent with RunAtLoad + KeepAlive. No sudo; --dry-run and --uninstall included on the scripts.
$ open ~/.config/deployboard/config.jsonEither list your namespaces under inventory.ours in config.json, or add your project folders to derive_roots and let the plist paths classify themselves. Anything unclassified shows up as Other — one click in the UI pins it, and config.json hot-reloads.
The dashboard opens on 127.0.0.1. Scrape /metrics from Prometheus, toggle alerts per application with the bell, and switch the theme between System, Light and Dark.
status values
six from upstream + disabled
config hot reload
no restart to classify a new app
Prometheus series families
jobs, runs, exit codes, probes
the only address it binds to
no cloud, no telemetry, no update ping
FAQ
The questions a fork has to answer.
Including the one about how this relates to upstream.
How is this different from launch-pilot, which it forks?
Everything upstream does is still here — the job table, log tail, six diagnostics, start/stop/reload. Deployboard adds the inventory view (ours/other/noise with path derivation), launchctl print enrichment (runs and disabled), a Prometheus endpoint, Telegram alerts with per-application toggles, read-only mode, and three install paths: Homebrew cask, GitHub Release binary (install-release.sh), and install.sh from a clone.
How does it know which jobs are mine?
Two rules, in order: an explicit allowlist you write (inventory.ours), then derivation — if the plist's log path, working directory, program or arguments sit inside a directory you listed under derive_roots, the job is yours. Everything the plist points at is evidence, so no guessing and no domain heuristics.
What if it gets a job wrong?
It shows up as Other, tagged "unclassified", and you pick Auto / Ours / Hide from the row. That writes config.json, which the server hot-reloads in about two seconds. Nothing is hidden silently: the full launchd list is always one chip away.
Does any of this leave my machine?
No. The HTTP server binds to 127.0.0.1 only, there is no analytics or phone-home, and the only outbound call the whole project can make is a Telegram message you explicitly enabled — using a token stored in your own Keychain.
Can I use it only for metrics, without the alerts?
Yes. Alerts default to on for your own apps and off for everything else; disable them per application, per category, or entirely. /metrics and the JSON API keep working regardless, and --read-only refuses every mutating call.
Why does it never edit my plists?
Deliberately. Deployboard reads launchd state and can ask launchctl to start, stop or reload a label, but it will not rewrite a plist on disk. If you want a plist editor, LaunchControl is the established paid option; this tool is about knowing what your deployments are doing.