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.
- 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-to-tip, and dead letters.
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
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
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). Set an RPC
variable for each network you plan to create (SEPOLIA_RPC_URL if you
follow the examples in this wiki).
Every url_secret a network resource names must resolve to a variable
present here, because blockwatcher resolves secrets inside its own container.
Then start the stack:
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.
From there, create a network under Resources → networks, a spec if your
selectors need ABI decoding, and a monitor with ui-ingest checked under
Actions: matches appear in the monitor’s log within a few seconds of
qualifying on-chain activity.
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, kept 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. 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.