Skip to content
Deployboardv0.0.3

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.

$brew install --cask francistse/tap/deployboard
installone commandbrowserstatus · logs · alertsexportsPrometheus /metrics
127.0.0.1:9410 · Deployboard
Deployboard
console for your own launchd services
SystemLightDark⚙ Settings
16 on · 2 off16 ours · 1 other · 528 noisewrite modehttp://127.0.0.1:9410
Search labels…
All (545)Running (163)Scheduled (2)Completed (0)Stopped (239)Error (139)Offline (1)Disabled (1)
LabelPIDExitRunsActions
Example App3105 jobs
com.example.app.apiOurslisted
730872h 14m028
🔔RestartLogs
com.example.app.webOursauto (path)↻ churn
730916d 3h01207↻
🔔RestartLogs
com.example.app.collectorOursauto (path)
7310418m021
🔔RestartLogs
com.example.worker.nightlyOurslisted
—13
🔔StartLogs
com.example.backup.weeklyOursauto (path)next · Sun 03:00
—00
🔔StartLogs
Ops0012 jobs
com.example.worker.oldOurslisted⏹ retired 12d
——0
EnableLogs
com.example.legacy.agentOursauto (path)
—00
StartLogs
GET /api/eventstext/event-stream5s push

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.

Without Deployboard
  • 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
With Deployboard
  • 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.

Running

PID > 0 — launchd print agrees it is alive

Scheduled

PID = 0, clean exit, schedule defined, no recent log

Completed

PID = 0, clean exit, log mtime inside --recent-window

Stopped

PID = 0, clean exit, no schedule, no recent log

Error

Last exit status non-zero

Disabled

launchctl print-disabled says disabled, no live pid — retired, not broken

Offline

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.

01

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.

02

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.

03

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".

04

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.

still here from upstream
Log tail

stdout + stderr per job, up to 10,000 lines, live-filtered.

Six diagnostics

exit code, program exists, program executable, plist owner, plist permissions, log path.

SSE, not polling

GET /api/events pushes the full job list every 5s.

One-click control

start / stop / reload through launchctl, with a confirmation dialog.

JSON API

the same surface the UI consumes, documented and curl-friendly.

Zero deps

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.

GET /metrics
# HELP deployboard_job_up 1 when the launchd job is running.
# TYPE deployboard_job_up gauge
deployboard_job_up{label="com.example.app.api",category="ours",group="Example App"} 1
deployboard_job_runs{label="com.example.app.web",category="ours",group="Example App"} 1207
deployboard_job_disabled{label="com.example.worker.old",category="ours"} 1
deployboard_job_probe_reachable{label="com.example.app.api",port="8001"} 1
deployboard_job_probe_latency_seconds{label="com.example.app.api",port="8001"} 0.003
deployboard_jobs_total{category="ours"} 16
deployboard_jobs_total{category="noise"} 528
promql
# restart churn, per job, over 6h
deriv(deployboard_job_runs{category="ours"}[6h]) * 3600
# anything of yours that is not running
deployboard_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.

✈telegram · deployboard
🔴 RUN_STORM
Example App · com.example.app.web
status: running
restarts: 1207
2026-10-03 10:41:07 +0800
triggerkind
status → errorerror
plist present, not loadedoffline
port stopped answeringunreachable
runs +25 between sweepsrun_storm
back to runningrecovery
Per-application switches, a cooldown, quiet hours, and recovery messages that quiet hours never swallow.

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.

verbpathdescription
GET/api/inventorycategory + group counts, and how each job was classified
POST/api/inventory/classifypin a job as ours / hide it / back to auto
GET/metricsPrometheus exposition — jobs, runs, exit codes, probes
GET/api/jobsList every job, including offline plists
GET/api/jobs/{label}Single-job snapshot (status, pid, runs, disabled)
GET/api/alertsper-application alert state + enabled/disabled counts
POST/api/alerts/{label}toggle alerts: {"enabled": false}
POST/api/alerts/{label}/testsend one test alert for that job
GET/api/settings/telegramtoken stored? chat id? — never the token itself
GET/api/jobs/{label}/logs?lines=200Tail stdout + stderr (max 10,000)
GET/api/eventsSSE stream — full job list pushed every 5s
POST/api/jobs/{label}/reloadlaunchctl bootout + bootstrap (403 in read-only mode)

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.

01 · install
$ brew install --cask francistse/tap/deployboard

Homebrew 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.

02 · point it at your apps
$ open ~/.config/deployboard/config.json

Either 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.

03 · watch it, or scrape it

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.

7

status values

six from upstream + disabled

2s

config hot reload

no restart to classify a new app

12

Prometheus series families

jobs, runs, exit codes, probes

127.0.0.1

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.