Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.hash bytes to an unprefixed hash, and links both to StellarExpert (testnet when the network id contains testnet). 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 422 on evm-mempool and stellar-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/networks from the browser reaches the companion server first, which forwards it to blockwatcher’s real GET /networks and 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 webhook sink named ui-ingest in blockwatcher automatically on boot. Any monitor whose actions include ui-ingest has its matches delivered (over HTTP, retried and dead-lettered by the engine exactly like any other webhook sink) to the companion’s /ingest endpoint, which stores them in ui.db for the log view. A monitor that omits ui-ingest from 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.