Skip to content

Web UI

For operators · running scans

How-to

adler --web boots a small in-process HTTP server and serves a SolidJS SPA from the same binary — no separate frontend deployment, no extra process to manage. Once the server is up, kick off scans, watch outcomes stream in over SSE, persist them to disk, and diff them against earlier runs.

Terminal window
adler --web # http://127.0.0.1:8080
adler --web --web-bind 0.0.0.0:9000 # listen on all interfaces, custom port

Outcomes stream in as they resolve (SSE), grouped by category, with per-row evidence (verdict reason, response snippet, URL) and a one-click retry.

Live scan view: outcomes streaming in by category
Live scan view, mid-stream. Categories on the left, outcomes painting as SSE events arrive.

Each row shows the verdict (Found / NotFound / Uncertain), the elapsed time, the verdict reason for Uncertain rows, and a small transport chip since v0.10 when the probe used anything other than the default HTTP transport — impersonate or browser. A * suffix (e.g. browser*) marks an outcome where the cheap path returned an Uncertain(cloudflare_challenge | rate_limited) and the router automatically escalated through the browser. The common Http+0 case stays uncluttered.

ResultRow close-up with transport chip and escalation marker
The browser* chip on the meta column marks an outcome where the cheap HTTP path returned Uncertain(cloudflare_challenge) and the router automatically escalated.

Every finished scan is persisted to ~/.cache/adler/scans/ (oldest 200, atomic writes). Reopen any past scan via #/scan/<id> deep-links.

History drawer with the last few scans
The History drawer (clock icon) lists every finished scan. Click any row to reopen, or arm a scan as the "compare with" source for a diff.

Pick any two persisted scans and diff them side-by-side (#/diff/<a>/<b>); shows accounts gained / lost / flipped between the two runs. Esc / back-button exits.

Side-by-side diff of two scans of the same username
Diff view at #/diff/<a>/<b>. Accounts gained, lost, and flipped between the two runs surface in three columns.

By verdict, category, presence of evidence, hidden NotFound rows. Preferences persist to localStorage.

Off by default; the toggle is hidden behind a confirmation, matching the CLI’s --nsfw opt-in.

The shield icon in the top bar opens a read-only panel showing what’s loaded from --proxy-pool (name, country, kind per egress — never proxy URLs) and --sessions (names only, never header values). Sensitive material is kept off the HTTP API by design; editing happens by updating the TOML files and restarting the server.

Access engine modal: egress pool and session names, no secrets
The shield icon's modal: configured egresses (name, country, kind) and session names. Proxy URLs and session header values never appear in any HTTP API response.

When a pool is loaded, Advanced filters shows an Egress section that toggles named entries from the pool; the next scan routes through that subset only. Sites whose access policy can’t be satisfied by the chosen subset land in Uncertain(geo_unavailable) — same honest verdict as if no egress matched at all.

The server exposes a small JSON API at /api/* — useful if you want to drive Adler from a different frontend or a script:

MethodPathPurpose
GET/api/healthLiveness probe.
GET/api/sitesSite catalogue available to scans.
GET/api/accessRead-only access-engine view (no secrets).
GET/api/scansRecent scans (in-memory + persisted).
POST/api/scanStart a scan; returns a scan_id.
GET/api/scan/:idFinal aggregate (or 202 in-progress / 404).
GET/api/scan/:id/streamServer-Sent Events stream of outcomes.
POST/api/scan/:id/retryRe-probe a single site.

SSE consumers should subscribe to the /stream endpoint and treat each event as one outcome.

The request body accepts an optional egress_names: string[] field; when non-empty, the scan routes through only the named subset of the pool. Unknown names return a 400 unknown_egress error with the bad entries enumerated in the message field — a typo shouldn’t silently turn into “nothing matched”.

POST /api/scan
{
"username": "alice",
"tag": ["dev"],
"egress_names": ["us-residential"]
}

The bundled SPA is baked into the binary at compile time (rust-embed), so the deployed unit is just the adler executable plus whatever scan- cache directory you point it at.

The SolidJS project lives at adler-server/web/; if you build from source, run npm ci && npm run build there before cargo build — Vite emits web/dist/, which rust-embed reads directly.

adler --web binds to 127.0.0.1 by default. --web-bind 0.0.0.0:9000 exposes the API on every interface; if you do that, anyone on the network can reach the JSON API. The access-engine endpoints deliberately omit proxy URLs and session header values so even an exposed /api/access won’t leak secrets — but a wide-open POST /api/scan still lets a stranger consume your --proxy-pool and --browser-budget. Put a reverse proxy with auth in front of any non-loopback bind.