A live network health dashboard for the Stellar network, built for Stellar and Soroban developers who want a quick read on network conditions (ledger close time, base fees, congestion, throughput) while building and testing.
Pulls real, live data from Stellar's public Horizon API — mainnet by default, with testnet selectable from the header. No mocked data.
See PRD.md for scope and metrics, and ARCHITECTURE.md for how data is fetched and how the dashboard stays live.
Building against NetPulse? docs/API.md is the reference for
all eight REST routes and the /ws WebSocket channel — response shapes, field
descriptions, which fields are nullable and why, and the behaviours that are
easy to get wrong.
Phase 2. The Phase 1 MVP shipped and has since gained live Horizon SSE streaming with WebSocket push to the browser, mainnet/testnet selection, persistent history with a 24h trend view and CSV/JSON export, Soroban contract activity metrics, an operation-type breakdown, and additional fee and transaction charts.
Also since Phase 1: congestion alerting over a webhook, graceful shutdown, a light/dark theme, an accessibility pass across the charts and palette, per-chart loading and error states, a long-range trends panel over daily rollups (30d/90d/1y), and network, history range and trend range reflected in the URL so a view is shareable.
See the open issues for the current backlog. See CONTRIBUTING.md for local setup, branch conventions, running checks, and how to claim an issue.
# backend
cd backend
cp .env.example .env
npm install
npm run dev
# frontend (separate terminal)
cd frontend
npm install
npm run devThe frontend dev server proxies both /api requests and the /ws
WebSocket to the backend on port 4000. Open the URL Vite prints (default
http://localhost:5173).
Configuration lives in backend/.env — see backend/.env.example for the
available settings and their defaults (including LOG_LEVEL, defaulting to info).
The backend writes its history database to DATABASE_PATH (default ./data/netpulse.db),
creating the directory on first run.
Set ALERT_WEBHOOK_URL and the backend POSTs when a network's ledger capacity
usage crosses CONGESTION_ALERT_THRESHOLD, and again when it recovers. Leave
it unset and nothing changes — alerting is off by default and no other
behaviour depends on it.
ALERT_WEBHOOK_FORMAT picks the body shape: generic (default), discord,
or slack. Discord and Slack both reject bodies they do not recognise, so
this has to match whatever the URL points at.
Alerts are edge-triggered: one notification when a network crosses into the alerting state, not one per poll while it stays there. Two independent guards keep a busy network from flooding a channel:
- Hysteresis (
ALERT_HYSTERESIS, default0.05) — the alert clears only once usage falls belowthreshold - hysteresis. A reading flickering either side of the threshold alerts once, not on every poll. - Cooldown (
ALERT_COOLDOWN_MS, default 15 minutes) — a floor on the time between alerts for one network, covering the case hysteresis cannot: a value swinging widely across both lines.
A recovery that closes a delivered alert is never suppressed, so an alert channel is not left showing a problem that has already passed. State is kept per network, so a mainnet episode neither triggers nor suppresses a testnet one. Delivery failures are logged and swallowed — a dead webhook cannot stop metric collection or take the dashboard down.
SIGTERM and SIGINT both start the same shutdown sequence, so the backend
drains cleanly under Docker, systemd, or any container platform that signals
before killing:
- Stop accepting new HTTP connections.
- Close WebSocket clients with a
1001("going away") close frame. - Drop idle keep-alive sockets and wait for in-flight requests to finish.
- Clear the Horizon poll interval and abort the SSE stream loops.
- Close the SQLite connection last, once nothing can still write to it.
The database is closed last on purpose: the SSE callbacks write ledgers as they arrive, so closing earlier risks a write against a closed handle. WAL-mode SQLite closed uncleanly on every restart is the failure this is most meant to avoid.
A second signal arriving mid-shutdown is ignored rather than starting cleanup
again. If cleanup exceeds SHUTDOWN_TIMEOUT_MS (default 8000) the process
logs the failure and exits non-zero, so a stuck connection cannot block exit
indefinitely. Keep that value below your platform's SIGTERM-to-SIGKILL grace
period (10s by default for both Docker and systemd).
CORS_ORIGIN controls which origins may call the REST API and open the
/ws WebSocket. It defaults to http://localhost:5173, so the Vite dev
server connects with no extra configuration.
The browser same-origin policy does not apply to WebSocket handshakes, so
this check — not CORS — is what stops an arbitrary page from opening /ws
and consuming the snapshot stream. A handshake from an origin that is not
listed is refused at the upgrade stage with 403 Forbidden and logged.
When deploying the frontend to a different origin than the backend, set
CORS_ORIGIN to that origin, or the browser's WebSocket connection will be
rejected:
# single origin
CORS_ORIGIN=https://netpulse.example
# several origins (apex plus www, or staging alongside production)
CORS_ORIGIN=https://netpulse.example,https://www.netpulse.example
# any origin — public read-only deployments only
CORS_ORIGIN=*Requests that send no Origin header — curl, monitoring scripts,
container health checks — are allowed. A browser always sends Origin on a
WebSocket handshake, so the cross-site connection this guards against cannot
occur without one, while anything outside a browser can set the header to
whatever it likes. Blocking origin-less clients would therefore break
legitimate tooling without stopping an attacker.
render.yaml at the repository root is a Render Blueprint describing the
backend as a single web service. It sets rootDir: backend, builds with
npm ci --include=dev && npm run build, starts with npm start, and uses the
existing /healthz route as its health check.
No application code is Render-specific. The server already reads PORT from
the environment and binds the unspecified address, and /ws shares that one
port with the REST API, so the service needs no code changes to run there.
-
In the Render dashboard, choose New → Blueprint and connect this repository. Render reads
render.yamland proposes thenetpulse-backendservice. -
When prompted for
CORS_ORIGIN, enter the frontend's origin — for examplehttps://netpulse.vercel.app. It accepts a comma-separated list, and the formats are described under Allowed Origins. Getting this wrong does not break the REST API in an obvious way, but the browser's WebSocket handshake is refused with403, so the dashboard loads and then never receives live updates. -
Deploy. The first build compiles TypeScript and takes a few minutes.
-
Confirm the service is up:
curl https://<your-service>.onrender.com/healthz # {"status":"ok"}
To change CORS_ORIGIN later — when the frontend's domain is settled, say —
edit it under the service's Environment tab. Render redeploys on save.
The Blueprint specifies the Starter instance type rather than Free. Free instances spin down after roughly 15 minutes without traffic, and this backend is a long-running poller: spin-down halts Horizon ingestion and disconnects every WebSocket client, so the dashboard goes stale rather than merely slow.
Recent data repopulates within a poll interval or two, so the dashboard's live
view recovers quickly. The longer /api/trends ranges do not: 30d, 90d,
and especially 1y need history that ephemeral storage never accumulates.
Attaching a Render persistent disk would fix this — set DATABASE_PATH to a
path on the mounted volume, which db.ts will create — but a disk requires a
paid instance and forces single-instance deploys with no zero-downtime
rollout. That tradeoff is tracked as a separate decision rather than assumed
here.
GET /api/history serves aggregated 5-minute buckets, and accepts an optional
format for downloading the same data:
| Request | Response |
|---|---|
/api/history |
application/json, rendered inline (what the dashboard fetches) |
/api/history?format=csv |
text/csv as a file download |
/api/history?format=json |
the same JSON body, as a file download |
format is a representation of the existing resource rather than a separate
endpoint, so network and range apply unchanged:
curl -OJ "http://localhost:4000/api/history?network=testnet&range=6h&format=csv"Downloads are named netpulse-history-<network>-<range>.csv (or .json).
Omitting format, or passing one that is not recognised, returns the original
inline JSON with no Content-Disposition.
CSV has one row per bucket. network and range are repeated on every row so
an exported file makes sense without the request that produced it, and empty
fields mean no usable value for that bucket:
network,range,timestamp,closeTimeSeconds,congestionUsage,operations,transactions,p50Fee,p90Fee
mainnet,24h,2026-09-02T22:10:00.000Z,5.58,0.6683,11371,6702,100,17734
See docs/API.md for the exact headers, RFC 4180 quoting and CRLF details, and why a cross-origin browser client cannot read the download filename. Note that an empty field means "no data or a zero aggregate" — the two are not distinguishable, which docs/API.md explains along with the other bucket behaviours (gaps are omitted rather than zero-filled, and the oldest bucket is usually partial).
GET /api/trends serves daily-grain history and accepts the same format
parameter, following the same pattern:
| Request | Response |
|---|---|
/api/trends |
application/json, rendered inline (what the dashboard fetches) |
/api/trends?format=csv |
text/csv as a file download |
/api/trends?format=json |
the same JSON body, as a file download |
network and range apply unchanged, and ranges here are 30d, 90d and
1y rather than the hourly ones history accepts:
curl -OJ "http://localhost:4000/api/trends?network=testnet&range=1y&format=csv"Downloads are named netpulse-trends-<network>-<range>.csv (or .json), so a
trends export never collides with a history export in the same folder.
CSV has one row per day, with network and range repeated on every row:
network,range,date,closeTimeSeconds,congestionUsage,maxCongestionUsage,operations,successfulTransactions,failedTransactions,p50Fee,p90Fee
mainnet,90d,2026-09-04,5.62,0.4213,0.9871,3427194,1044821,20713,137,9042
mainnet,90d,2026-09-05,,,,12,3,0,,
Two differences from the history export worth knowing before you load one into a spreadsheet:
- Successful and failed transactions are separate columns, where history has
a single
transactionscolumn holding their sum. Add the two to compare. - An empty field means no data, not a zero. Unlike history, a genuine zero
is written as
0. The second row above is a day with ledgers but no fee snapshots.
Days the backend was not running are absent rather than zero-filled, so consecutive rows are not necessarily consecutive dates. See docs/API.md for the full field reference and retention behaviour.
GET /healthz: Process liveness endpoint that returns{"status": "ok"}with HTTP 200 whenever the backend process is running and accepting HTTP requests. It performs no I/O, does not access the database, and does not depend on upstream Horizon connectivity. Use/healthzfor container orchestrator liveness checks.GET /api/health: Network metrics endpoint returning current network conditions (ledger close times, fee statistics, congestion banding). Because this reflects upstream Horizon reachability and may reportstatus: "stale"during external Horizon outages, it should not be used as a container liveness probe.
A stale response is still HTTP 200, so staleness cannot be detected from
the status code — see
docs/API.md for what exactly makes
a response stale, how it relates to secondsSinceLastUpdate, and the full
field-by-field reference.
backend/scripts/rollup-gap-check.mjs reports how much ledger data is absent
from a database and how much of that loss is already permanent.
# a local database (defaults to $DATABASE_PATH, else ./data/netpulse.db)
node backend/scripts/rollup-gap-check.mjs
# an explicit path, e.g. on a deployment host
node backend/scripts/rollup-gap-check.mjs /var/lib/netpulse/netpulse.db
# machine-readable
node backend/scripts/rollup-gap-check.mjs --jsonIt reports two things: gaps in the ledgers sequence per network, with the size
of each gap, and any daily_rollups rows whose raw rows have already been
pruned. The second set is the one that matters — daily totals are sums, so a
missing ledger is an undercount, and once the raw rows behind a day are deleted
at the retention boundary that undercount can no longer be recomputed.
Gap size is the diagnosis. One or two missing ledgers is the warm-up to stream cursor handoff; runs longer than the 20-ledger warm-up window are downtime that nothing backfilled.
Safe to run against a live production database. The connection is opened
read-only, so it cannot write to the file or block the backend. The database
runs in WAL mode, which gives readers a consistent snapshot without blocking the
writer, so no downtime or maintenance window is needed. And rollupAndPrune()
does its rollup and delete in a single transaction, so a read landing mid-prune
sees either the whole before state or the whole after state, never a half-pruned
day.
If you would rather analyse a copy, copy netpulse.db, netpulse.db-wal and
netpulse.db-shm together. Copying only the first gives a snapshot missing every
commit still in the WAL, which reads as data loss that is not real.
Finding gaps is not an error: the script exits 0 whatever it reports, and
non-zero only if the database cannot be read. A database with no daily_rollups
table predates that feature, which the script reports plainly — no rollups means
nothing has frozen yet.
MIT — see LICENSE.