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.
  • 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/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

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.