Stats & status API

Pull live fleet stats and infrastructure health over REST, MCP, or Realtime.

Pull live fleet stats and infrastructure health over REST, MCP, or Realtime — two single-row snapshot feeds, three ways to read them, all behind the same OAuth token.

Two live, read-only feeds describe the running system:

  • Service stats — what the fleet is doing: device counts, entry totals, request throughput, status/source/connection-type mixes, and the busiest domains + devices.
  • Service status — whether the infra is healthy: host CPU / load / memory / uptime plus a per-component health probe (HTTP reachability, latency, systemd unit state).

Each is available over all three programmatic surfaces — REST (api.busymate.net), MCP (mcp.busymate.dev), and Realtime WS (Supabase) — so you can poll once, call a tool, or subscribe for live pushes.

TL;DR

StatsStatus
REST RPCPOST /rest/v1/rpc/get_stats
REST tableGET /rest/v1/service_stats?id=eq.1&select=snapshotGET /rest/v1/service_status?id=eq.1&select=snapshot
MCP toolget_stats (no params)get_status (no params)
Realtimepostgres_changes UPDATE on public.service_stats (+ public.stats_rollup_state for the rollup seal)postgres_changes UPDATE on public.service_status
Fed bya 60s cron recomputes the snapshot from the counters ledger + the sealed rollupthe VPS status service every ~10s
Accesscapability stats:viewcapability status:view

Both snapshot tables are single-row (id = 1); the freshest value always lives at that row, and a write replaces the snapshot jsonb in place — so a Realtime UPDATE event is the live push.

Access — capability-gated

These surfaces are RBAC-gated, not public:

  • Reading stats requires the stats:view capability.
  • Reading status requires the status:view capability.

The built-in admin role grants both (it grants every capability); custom roles get them via Settings → Roles. RLS enforces this server-side through has_capability(section, action), so a token without the capability gets an empty result over REST/Realtime and an error over MCP — see Roles & permissions. Every surface authenticates with an OAuth access token (the same token type MCP, PostgREST, and Realtime all accept).

Shapes

Stats snapshot (get_stats / service_stats.snapshot)

jsonc
{
  "total_devices": 12,
  "online_devices": 4,
  "total_entries": 184203,                       // Σ entry_dh_counts.n — the counters LEDGER, never an ANALYZE estimate
  "total_entries_source": "ledger",              // "ledger" (live-maintained) | "snapshot" (hourly)
  "total_entries_as_of": "2026-09-03T10:00:00.000Z", // the ledger's own stamp
  "entries_1h": 512,
  "entries_24h": 9841,
  "requests_per_sec": 0.14,
  "status_class_mix":     { "2xx": 7321, "3xx": 88, "4xx": 410, "5xx": 12, "other": 0 },  // the sealed 24 h rollup
  "source_mix":           { "vpn": 6200, "pac": 1900, "cdp": 1741, "gdp": 96, "bmp": 40, "direct": 0, "other": 0 },
  "connection_type_mix":  { "vpn": 9, "pac": 3 },
  "rollup_window_hours": 24,
  "rollup_sealed_to": "2026-09-03T10:30:00.000Z", // the rollup seal the mixes + top-N describe
  "top_domains": [ { "host": "api.stripe.com", "count": 1840 } ],
  "top_devices": [ { "device_uuid": "…", "name": "BMH3", "count": 5120 } ],
  "generated_at": "2026-09-03T10:31:00.000Z"      // when this aggregate was computed
}

Which number is which (#2151). total_entries is the exact sum of the counters ledger (entry_dh_counts), stamped by total_entries_as_of; status_class_mix, source_mix, top_domains and top_devices are exact for the sealed rollup window (rollup_sealed_to is the newest 5-minute bucket the rollup is exact through — captures newer than that are not in the mixes yet); entries_1h / entries_24h / requests_per_sec are live counts at generated_at. The dashboard /stats board reads the same facts: its headline is the ledger, its status/source mixes come from the scoped stats_board_rollup(target_workspace, device_uuids, window_hours) RPC (re-read on the stats_rollup_state:all push), and only the method / HTTPS / bytes cards are computed over the loaded buffer — labelled "of the last N loaded".

Counter freshness (get_entry_counts_freshness)

Every entries count on the dashboard — the Devices panel, the feed header, the /stats headline and total_entries above — reads the same counters ledger, maintained in the write transaction of every capture and every wipe (see Wipe history & counters). One read reports how fresh that ledger is; from dashboard 838 it is the MCP tool get_entry_counts_freshness (capability devices:view):

jsonc
{
  "mode": "live",                                   // the ledger is maintained at the write seam and pushed as COUNTS_DELTA
  "ledger_since": "2026-09-03T08:10:00.000Z",       // when live maintenance began
  "reconciled_at": "2026-09-03T10:07:12.000Z",      // the hourly reconciler's last completed pass
  "reconcile_stale": false,                         // true once 2 h pass without a reconciler pass (reported, never silent)
  "drift_rows": 0,                                  // ledger rows the last pass corrected
  "drift_abs": 0,                                   // the absolute count the last pass corrected
  "last_error": null,                               // the reconciler's last failure, if any
  "stats_rollup_sealed_to": "2026-09-03T10:30:00.000Z", // = rollup_sealed_to above — the seal the /stats mixes are exact through
  "stats_rollup_stale": false                       // true when the rollup seal is older than 30 min
}

Read it as: the counts are live (mode, ledger_since), the check on them is the reconciler (reconciled_at, reconcile_stale, the drift it found), and the mixes are the rollup seal (stats_rollup_sealed_to, stats_rollup_stale). The dashboard's freshness marker (live / as of HH:MM / counts pending) is rendered from this same read plus whether a Realtime subscription is attached.

Status snapshot (get_status / service_status.snapshot)

jsonc
{
  "overall": "ok",                       // ok | degraded | down
  "host": {
    "cpu":    0.07,                       // load fraction 0..1
    "load":   [0.21, 0.18, 0.15],         // 1/5/15-min load averages
    "mem":    { "usedMb": 1840, "totalMb": 3920 },
    "uptime": 1820394                     // seconds
  },
  "components": [
    {
      "key": "dashboard",
      "label": "Dashboard",
      "probe":     { "up": true, "httpStatus": 200, "latencyMs": 42 },
      "unitState": "active"               // systemd ActiveState, where applicable
    }
  ]
}

overall rolls up the component probes; a component is unhealthy when its probe.up is false (or its unitState isn't active).

REST (api.busymate.net)

api.busymate.net is a CNAME to the Supabase project, so these are plain PostgREST calls. Pass your token as Authorization: Bearer <token> and apikey.

Stats via the RPC (computes a fresh snapshot on call):

sh
curl -s -X POST https://api.busymate.net/rest/v1/rpc/get_stats \
  -H "Authorization: Bearer $TOKEN" \
  -H "apikey: $TOKEN" \
  -H "content-type: application/json" \
  -d '{}'

Status — read the cached snapshot (cheap; updated every ~10s by the VPS status service):

sh
curl -s "https://api.busymate.net/rest/v1/service_status?id=eq.1&select=snapshot" \
  -H "Authorization: Bearer $TOKEN" \
  -H "apikey: $TOKEN"

Stats — read the cached snapshot (cheaper than the RPC; refreshed by the 60s cron):

sh
curl -s "https://api.busymate.net/rest/v1/service_stats?id=eq.1&select=snapshot" \
  -H "Authorization: Bearer $TOKEN" \
  -H "apikey: $TOKEN"

Both table reads return a one-element array; the payload is in [0].snapshot.

RPC vs. table. rpc/get_stats recomputes on every call (freshest, but heavier). The service_stats table holds the last cron-computed snapshot — read it when "up to 60s old" is fine, or subscribe to it for pushes. There is no rpc/get_status; status is always read from the service_status snapshot table (or the MCP get_status tool, which reads the same row).

MCP (mcp.busymate.dev)

Three parameter-less tools on the busymate-devtools MCP server:

ToolReturns
get_statsThe stats snapshot (same shape as the RPC).
get_statusThe status snapshot (host + component probes).
get_entry_counts_freshnessThe counter freshness read — ledger mode, reconciler pass + drift, rollup seal (from dashboard 838; devices:view).
json
{"jsonrpc":"2.0","id":1,"method":"tools/call",
 "params":{"name":"get_stats","arguments":{}}}
json
{"jsonrpc":"2.0","id":2,"method":"tools/call",
 "params":{"name":"get_status","arguments":{}}}

Send either as the -d body of the MCP JSON-RPC request — see MCP → Connect. The result is a content[].text JSON string.

Realtime WS (live pushes)

Subscribe to postgres_changes UPDATE events on the two single-row tables to get the snapshot pushed the instant it changes — no polling. service_status updates every ~10s; service_stats every 60s. A third single-row table, stats_rollup_state, pushes an UPDATE every time the rollup seals a new bucket (last_sealed_bucket) — the dashboard /stats board re-reads its mixes on that push and never on a timer; subscribe it the same way (stats:view).

js
import { createClient } from "@supabase/supabase-js";
 
const supabase = createClient("https://api.busymate.net", PUBLISHABLE_KEY, {
  global: { headers: { Authorization: `Bearer ${TOKEN}` } },
});
 
const channel = supabase
  .channel("service-monitors")
  .on(
    "postgres_changes",
    { event: "UPDATE", schema: "public", table: "service_status", filter: "id=eq.1" },
    ({ new: row }) => console.log("status →", row.snapshot.overall, row.snapshot.host),
  )
  .on(
    "postgres_changes",
    { event: "UPDATE", schema: "public", table: "service_stats", filter: "id=eq.1" },
    ({ new: row }) => console.log("stats →", row.snapshot.requests_per_sec, "req/s"),
  )
  .subscribe();

The new.snapshot on each event is the full jsonb above — read the current value once over REST on connect, then let Realtime keep it fresh. The same stats:view / status:view capability gates the Realtime subscription, so a token lacking it simply never receives rows.

Where the snapshots come from

VPS status serviceevery ~10sstats cronevery 60sservice_statusid=1 · snapshotservice_statsid=1 · snapshotreadersREST · MCPRealtime WS

Troubleshooting

SymptomCause / fix
REST read returns [] (empty array)Your token lacks stats:view / status:view, or the snapshot row hasn't been written yet. Check your role in Roles & permissions.
MCP get_stats / get_status errors with a permission messageSame capability gate as REST — the OAuth token's user role lacks the capability.
Realtime never delivers an eventThe capability gate also applies to subscriptions; a token without it silently receives no rows. Confirm the read works over REST first.
service_status looks stale (> ~30s old)The VPS status service is down or wedged — the overall/host won't refresh. Check the dashboard status surface or the VPS unit.
service_stats up to 60s oldExpected — it's cron-refreshed every 60s. Call rpc/get_stats for an on-demand fresh compute.
Ask your mate