The dashboard
Everything so far has driven blockwatcher by hand: editing JSON files, calling the
REST API with curl. blockwatcher ships an optional local web UI that does the
same operations (resource CRUD, watching matches arrive) through a browser
instead.
What it shows
- A monitors fleet table: every monitor, active or paused, with a live summary of its selectors and uptime.
- Per-monitor stats: pause/resume, a dry-run against the live decoder and matcher (either raw test payloads or a bounded history scan), and stat cards for the monitor’s counters.
- A virtualised match/event log per monitor, built to stay responsive
even once a monitor has produced a large number of matches. Opening a
match translates Stellar flavored hex addresses to G/C/M StrKeys and
tx.hashbytes to an unprefixed hash, and links both to StellarExpert (testnet when the network id containstestnet). EVM matches link to Etherscan when the network id names a known host (sepolia,mainnet). - CRUD for every resource kind: networks, specs, sinks, and monitors, as either a structured form (for monitors, a selector editor with a schema helper) or raw JSON.
- A networks page with per-network operational state: source status,
lag, queue depth, cursor, pause/resume, skip (tip, or a block/ledger
number), and dead letters. Skip-to-tip is
422onevm-mempoolandstellar-rpc(those sources cannot report a confirmed tip); skip to a ledger/block number still works while paused.
Two services, not one
The dashboard is not a feature you turn on inside the blockwatcher binary: it’s
a second, independent process that sits in front of it. blockwatcher itself keeps
running exactly as described everywhere else in this wiki: same REST API,
same pipelines, same SQLite store, blockwatcher.db. What changes is that nothing
outside its own container ever talks to it directly: its listen port isn’t
published on the host at all. A second binary, blockwatcher-ui-server, built from
Rust (Axum) and serving a compiled React single-page app, is the only thing
that binds a host-reachable port (127.0.0.1:8080), and it holds its own
SQLite database, ui.db, entirely separate from blockwatcher’s.
That server does two unrelated jobs behind the one port: it forwards every API call the browser makes through to the real blockwatcher API and relays the answer back, and it exposes an ingest endpoint that blockwatcher itself calls into to hand over matches. The first job is why a browser never needs to know blockwatcher’s address; the second is covered next.
flowchart LR
browser["Browser"]
subgraph ui_svc["ui service (127.0.0.1:8080, host-reachable)"]
spa["React SPA"]
srv["Companion server (Axum)"]
uidb[("ui.db")]
end
subgraph ob_svc["blockwatcher service (internal only)"]
api["REST API"]
eng["Engine · pipelines"]
obdb[("blockwatcher.db")]
end
browser --> spa
spa --> srv
srv -->|"proxy /api/blockwatcher/..."| api
api --- eng
eng -->|"webhook POST /ingest"| srv
srv --> uidb
eng --> obdb
Compose never publishes the engine on the host. Without Docker the engine
still only accepts the companion as a client, but it does listen on
loopback (127.0.0.1:9080) so the companion can reach it; Vite on :5173
is a third process that proxies the browser to the companion.
Two consequences follow from that shape:
- Every API call the browser makes is really two hops.
GET /api/blockwatcher/networksfrom the browser reaches the companion server first, which forwards it to blockwatcher’s realGET /networksand relays the response back: blockwatcher is never addressable directly. - Matches reach the dashboard the same way any other consumer would: as a
sink. The companion server registers a
webhooksink namedui-ingestin blockwatcher automatically on boot. Any monitor whoseactionsincludeui-ingesthas its matches delivered (over HTTP, retried and dead-lettered by the engine exactly like any other webhook sink) to the companion’s/ingestendpoint, which stores them inui.dbfor the log view. A monitor that omitsui-ingestfrom its actions runs and delivers to its other sinks as normal, but never shows up in the dashboard’s log.
Running it
Two topologies. Compose hides the engine and serves the built SPA on
127.0.0.1:8080. A cargo run example needs three local processes: the
engine on 9080, the companion on 8080, Vite on 5173. The
ui/README.md local-development section keeps the same commands next to
the UI crate.
With Docker
From the repository root, copy and fill in the compose environment file:
cp docker/.env.example docker/.env
Edit docker/.env: set BLOCKWATCHER_API_TOKEN, UI_OPERATOR_SECRET, and
UI_INGEST_SECRET (the last two must not be the same value). The compose
seed (examples/compose-demo)
names SEPOLIA_RPC_URL and STELLAR_RPC_URL; the latter’s example value
is the public Stellar Testnet node. Every url_secret a network resource
names must resolve to a variable present here, because blockwatcher
resolves secrets inside its own container.
Stamp recent cursors into both seeded networks, then start the stack.
--seed only loads into an empty store, so down -v is required when
this volume previously booted a different seed:
./examples/compose-demo/setup.sh
docker compose -f docker/compose.yaml down -v
docker compose -f docker/compose.yaml up --build
The first run builds two images: one for blockwatcher itself, one for the
companion server plus the built SPA. The ui container waits for
blockwatcher’s health check (GET /health, no token) before serving. Open
http://127.0.0.1:8080 and sign in with UI_OPERATOR_SECRET.
The seed already created sepolia and stellar-testnet, with monitors
that include ui-ingest under Actions, so matches appear in the log
within a few seconds of qualifying on-chain activity. Pause either
network on Networks if you only want one pipeline. Create further
networks, specs, or monitors under Resources the same way you would
outside Docker.
Alongside a local example (no Docker)
The in-tree examples/source-*-monitor trees listen on 8080 and deliver
to log-sink. The companion also wants 8080. Point the engine at
9080, put UI_INGEST_URL in that process (blockwatcher resolves
env:… from its own environment), then start the companion and Vite.
Export these in every terminal:
export BLOCKWATCHER_API_TOKEN=dev
export UI_OPERATOR_SECRET=dev-operator
export UI_INGEST_SECRET=dev-ingest # must differ from UI_OPERATOR_SECRET
export UI_INGEST_URL=http://127.0.0.1:8080/ingest
Also export whatever the network JSON names (SEPOLIA_SUBGRAPH_URL,
SEPOLIA_RPC_URL, …).
Terminal 1 — engine. Shipped examples listen on 8080. The companion
needs that port. Change [api] listen before cargo run. Leave it at
8080 and GET /login is JSON a bearer token is required from the
engine, not a sign-in form.
[api]
listen = "127.0.0.1:9080"
cd examples/source-evm-subgraph-monitor # or any source-*-monitor example
grep 'listen = "127.0.0.1:9080"' blockwatcher.toml
./setup.sh # first time / empty db
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources
# later boots: drop --seed; the sqlite store is authoritative
The engine log must show addr=127.0.0.1:9080.
Terminal 2 — companion.
cd ui/server
export BLOCKWATCHER_API_URL=http://127.0.0.1:9080
cargo run
Wait for listening on 127.0.0.1:8080. That log line means the port
accepts connections; it does not wait for ui-ingest to register.
Until that sink exists, /login answers 503 with a waiting page
(Retry-After: 3) rather than resetting the handshake. Address already in use means the engine still owns 8080 — do not start Vite. A 422
retry loop in the companion log means UI_INGEST_URL is unset in the
engine — restart terminal 1 with it set. Once the sink is registered,
/login is the sign-in page:
curl -sS http://127.0.0.1:8080/login | head
# waiting: HTML "blockwatcher UI is starting"
# ready: HTML containing "Sign in — blockwatcher"
# JSON unauthorized → still the engine
Sign in at http://127.0.0.1:8080/login with UI_OPERATOR_SECRET. That
is the dashboard. Vite on :5173 is only for hot-reloading the React app,
and its /login proxy is the companion only after this check passes.
Terminal 3 — SPA (optional).
cd ui/web
npm install
npm run dev
Networks and status work immediately. Matches appear in the log only
after that monitor’s Actions include ui-ingest (the examples ship
log-sink only; keep both if you still want stdout). Set it in the
monitor form, or GET the monitor, add the action, and PUT with
If-Match.
What the companion does and does not do
Operators sign in at /login with UI_OPERATOR_SECRET and hold an
HttpOnly; SameSite=Strict session. The engine’s webhook authenticates
with UI_INGEST_SECRET on x-blockwatcher-ingest; the two secrets must
not be the same value. Compose still binds the dashboard to 127.0.0.1
because loopback is what keeps the login page off the wider network;
re-point that bind to 0.0.0.0 only behind TLS (UI_COOKIE_SECURE=true)
and a deployment story that is not “the operator’s laptop”. The engine
API’s GET /health remains unauthenticated so Compose can probe it. The
trust boundary is written out in the repository’s docs/threat-model.md.
Where the data actually lives
Two SQLite files back the two services. In Compose they live in named
volumes rather than bind mounts so container recreation doesn’t lose them:
blockwatcher’s own blockwatcher.db (the blockwatcher-data volume) holds
resources, checkpoints, and dead letters, and pause state, exactly as it
would outside Docker; the companion’s ui.db (the ui-data volume) holds
only what it has ingested: match history. Without Docker those are ordinary
files: blockwatcher.db next to the example’s blockwatcher.toml, and
ui.db in whatever directory you launched ui/server from. Pause state
lives entirely in blockwatcher.db; the companion reads it from /status
rather than keeping its own copy. Neither service reads the other’s file;
the only thing that crosses between them is whatever the ui-ingest
webhook forwards. docker compose down leaves both volumes in place for
the next up; only down -v removes them, and that removal is permanent:
every resource, checkpoint, and logged match is gone, with no confirmation
beyond the flag itself. Because match bodies can carry on-chain data an
operator considers sensitive, that risk applies to ui.db the same
way it applies to any consumer that stores what a webhook sink delivers.