Serve a read-only status page on the bridge port

GET / now returns a dashboard for the reader: mode, port, uptime,
card presence, the current card, event counters and a live event log
seeded from /api/taps and updated over the /api/events SSE stream.

The page is read-only on purpose - it has no claim button, because
POST /api/card/claim spends the tap and merely viewing the status
would otherwise consume a real login.

Serving the page requires allowing same-origin browser requests: a
browser sends Origin even for a request to the same origin, so
_cors_ok() used to reject the page's own API calls with 403 unless
--allow-origin was passed. Loopback and same-host origins are now
accepted, and the Access-Control-Allow-Origin echo is applied
consistently in one helper instead of four copies.

--no-page reverts POST-free deployments to API-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-10-05 21:34:17 +03:00
parent c720f4ca67
commit 882b89d6da
4 changed files with 436 additions and 14 deletions
+25
View File
@@ -309,10 +309,31 @@ cleared, so it cannot be mistaken for a tap still waiting to be used.
> per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`) > per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`)
> avoids the gap for a card held in place. > avoids the gap for a card held in place.
### Status page
Serving also puts a **status page** at `GET /` on the same port, so you can see
what the reader is doing from a browser:
```
http://127.0.0.1:8765/
```
It shows the reader's mode, port, uptime, whether a card is present and
claimable, the current card, the event counters, and a live event log. The log
is seeded with recent history from `/api/taps` and then updated live over the
`/api/events` SSE stream, so it keeps filling in without a refresh.
The page is **read-only**: it deliberately has no "claim" button, because
`POST /api/card/claim` spends the tap, and a page that claimed would silently
consume a real login just for being open. Claim from your own app instead.
Disable it with `--no-page` if you want the port to serve the API only.
### Endpoints ### Endpoints
| Method | Path | Purpose | | Method | Path | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/` | Status page (unless `--no-page`) |
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` | | `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired | | `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens | | `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
@@ -479,6 +500,9 @@ A healthy startup logs one line:
### Checking the reader from the command line ### Checking the reader from the command line
```bash ```bash
# Everything at once, in a browser: the status page
xdg-open http://127.0.0.1:8765/ # or open it on another machine
# Is the reader alive? (status, port, uptime) # Is the reader alive? (status, port, uptime)
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool
@@ -882,6 +906,7 @@ src/rdm_card_reader/
demo.py simulated reads demo.py simulated reads
ports.py stable serial port identification ports.py stable serial port identification
bridge.py the HTTP bridge behind --serve bridge.py the HTTP bridge behind --serve
webui.py the status page served at GET / by the bridge
led.py status LED led.py status LED
bootstrap.py offline dependency install bootstrap.py offline dependency install
wheels/ the bundled wheels (offline install source) wheels/ the bundled wheels (offline install source)
+71 -14
View File
@@ -55,8 +55,10 @@ import time
from collections import deque from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Deque, Dict, List, Optional from typing import Any, Callable, Deque, Dict, List, Optional
from urllib.parse import urlsplit
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
from .webui import render_page
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication #: Reader modes. ``login`` publishes inserts only (a tap = an authentication
#: attempt). ``monitor`` publishes inserts and removals both. #: attempt). ``monitor`` publishes inserts and removals both.
@@ -325,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
# -- helpers ---------------------------------------------------------- # -- helpers ----------------------------------------------------------
def _origin_is_loopback(self, origin: str) -> bool:
"""True when *origin* is this server itself or a loopback address.
A browser sends ``Origin`` even for same-origin requests, so without
this the status page served from this very port could not call its own
API. A remote site cannot forge ``Origin: http://127.0.0.1``, so
trusting loopback here does not widen what an attacker can reach.
"""
host = self.headers.get("Host")
if host and origin in ("http://" + host, "https://" + host):
return True
try:
hostname = (urlsplit(origin).hostname or "").lower()
except ValueError:
return False
return hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0")
def _cors_ok(self) -> bool: def _cors_ok(self) -> bool:
"""Refuse cross-origin reads unless the origin was allow-listed.""" """Refuse cross-origin reads unless the origin was allow-listed."""
origin = self.headers.get("Origin") origin = self.headers.get("Origin")
if not origin: if not origin:
return True # same-origin fetch or a non-browser client return True # same-origin fetch or a non-browser client
if self._origin_is_loopback(origin):
return True
allowed: List[str] = getattr(self.server, "allowed_origins", []) allowed: List[str] = getattr(self.server, "allowed_origins", [])
if not allowed:
return False # browsers only; opt out entirely
return origin in allowed return origin in allowed
def _token_ok(self) -> bool: def _token_ok(self) -> bool:
@@ -351,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
supplied = pair.split("=", 1)[1] supplied = pair.split("=", 1)[1]
return secrets.compare_digest(supplied, required) return secrets.compare_digest(supplied, required)
def _allowed_origin(self) -> Optional[str]:
"""The Origin to echo in ``Access-Control-Allow-Origin``, or None.
Same-origin and loopback callers are echoed so the dashboard can read
its own API; any other origin must be on the allow-list.
"""
origin = self.headers.get("Origin")
if not origin:
return None
if self._origin_is_loopback(origin):
return origin
if origin in getattr(self.server, "allowed_origins", []):
return origin
return None
def _send_json(self, payload: Any, status: int = 200) -> None: def _send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
origin = self.headers.get("Origin") allowed = self._allowed_origin()
if origin and origin in getattr(self.server, "allowed_origins", []): if allowed:
self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def _send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _send_page(self) -> None:
self._send_bytes(render_page(), "text/html; charset=utf-8")
def _send_no_content(self) -> None: def _send_no_content(self) -> None:
self.send_response(204) self.send_response(204)
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
origin = self.headers.get("Origin") allowed = self._allowed_origin()
if origin and origin in getattr(self.server, "allowed_origins", []): if allowed:
self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
self.end_headers() self.end_headers()
@@ -388,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = self.path.split("?", 1)[0].rstrip("/") or "/" path = self.path.split("?", 1)[0].rstrip("/") or "/"
# The status page is served before the CORS and token checks, on
# purpose: it is static, reveals nothing on its own, and has to be able
# to load so it can prompt for a token when the API demands one.
if path == "/":
if getattr(self.server, "serve_page", True):
self._send_page()
else:
self._send_json({"error": "not found", "path": "/"}, status=404)
return
if path == "/api/health": if path == "/api/health":
self._send_json(self._hub.health()) self._send_json(self._hub.health())
return return
@@ -476,10 +531,10 @@ class _Handler(BaseHTTPRequestHandler):
self._send_json({"mode": mode}) self._send_json({"mode": mode})
def do_OPTIONS(self) -> None: # noqa: N802 def do_OPTIONS(self) -> None: # noqa: N802
origin = self.headers.get("Origin")
self.send_response(204) self.send_response(204)
if origin and origin in getattr(self.server, "allowed_origins", []): allowed = self._allowed_origin()
self.send_header("Access-Control-Allow-Origin", origin) if allowed:
self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token") self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
@@ -495,9 +550,9 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/event-stream") self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive") self.send_header("Connection", "keep-alive")
origin = self.headers.get("Origin") allowed = self._allowed_origin()
if origin and origin in getattr(self.server, "allowed_origins", []): if allowed:
self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Origin", allowed)
self.send_header("Vary", "Origin") self.send_header("Vary", "Origin")
self.end_headers() self.end_headers()
@@ -532,11 +587,13 @@ def create_server(
allowed_origins: Optional[List[str]] = None, allowed_origins: Optional[List[str]] = None,
token: Optional[str] = None, token: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None, logger: Optional[Callable[[str], None]] = None,
serve_page: bool = True,
) -> ThreadingHTTPServer: ) -> ThreadingHTTPServer:
"""Build (but do not start) the HTTP server around *hub*.""" """Build (but do not start) the HTTP server around *hub*."""
server = ThreadingHTTPServer((host, port), _Handler) server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True server.daemon_threads = True
server.hub = hub # type: ignore[attr-defined] server.hub = hub # type: ignore[attr-defined]
server.serve_page = serve_page # type: ignore[attr-defined]
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined] server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined] server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
server.logger = logger # type: ignore[attr-defined] server.logger = logger # type: ignore[attr-defined]
+5
View File
@@ -212,6 +212,7 @@ def _run_bridge(args) -> int:
allowed_origins=args.allow_origin, allowed_origins=args.allow_origin,
token=args.token, token=args.token,
logger=print if args.verbose else None, logger=print if args.verbose else None,
serve_page=not args.no_page,
) )
server_thread = threading.Thread(target=server.serve_forever, daemon=True, server_thread = threading.Thread(target=server.serve_forever, daemon=True,
@@ -220,6 +221,8 @@ def _run_bridge(args) -> int:
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format( print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
device, args.host, args.bridge_port, args.mode)) device, args.host, args.bridge_port, args.mode))
if not args.no_page:
print(" GET / <- status page in a browser")
print(" GET /api/card <- the card on the reader right now") print(" GET /api/card <- the card on the reader right now")
print(" POST /api/card/claim (single use, for one login)") print(" POST /api/card/claim (single use, for one login)")
print(" GET /api/events (SSE stream)") print(" GET /api/events (SSE stream)")
@@ -303,6 +306,8 @@ def build_parser() -> argparse.ArgumentParser:
"(or set RDM_BRIDGE_TOKEN)") "(or set RDM_BRIDGE_TOKEN)")
bridge.add_argument("--allow-remote", action="store_true", bridge.add_argument("--allow-remote", action="store_true",
help="permit binding a non-loopback address") help="permit binding a non-loopback address")
bridge.add_argument("--no-page", action="store_true",
help="do not serve the status page at GET /")
bridge.add_argument("--verbose", action="store_true", bridge.add_argument("--verbose", action="store_true",
help="also print each event and each HTTP request") help="also print each event and each HTTP request")
+335
View File
@@ -0,0 +1,335 @@
"""The status page served at ``GET /`` by the HTTP bridge.
Kept as a single string in a Python module rather than a packaged ``.html``
file on purpose: this way the page is present however the package is
installed - source checkout, editable install, or the venv the installer
builds - with no ``package-data`` entry to forget.
The page is read-only by design. It deliberately has no "claim" button:
``POST /api/card/claim`` spends the latched tap, so a status page that
claimed would silently consume a real login just for being open.
Everything lives in one file with no external assets, because the device it
runs on is usually a Raspberry Pi that may have no internet access at all.
"""
from __future__ import annotations
#: Poll interval for the status panels, in milliseconds. The event log is
#: pushed over SSE and does not wait for this.
REFRESH_MS = 2000
#: How many log rows the page keeps before dropping the oldest.
MAX_LOG_ROWS = 200
PAGE_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RDM card reader</title>
<style>
:root {
--bg: #14161a; --panel: #1c1f26; --line: #2b3038;
--fg: #e6e9ef; --dim: #8b93a1; --accent: #6ea8fe;
--ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 1rem; background: var(--bg); color: var(--fg);
font: 15px/1.45 var(--mono); -webkit-text-size-adjust: 100%;
}
header {
display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
padding-bottom: .75rem; border-bottom: 1px solid var(--line);
}
h1 { font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: .02em; }
h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--dim);
font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
.pill {
font-size: .75rem; padding: .2rem .55rem; border-radius: 999px;
border: 1px solid var(--line); color: var(--dim); white-space: nowrap;
}
.pill.up { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.pill.down { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
#token {
margin-left: auto; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 6px;
padding: .25rem .5rem; font: inherit; font-size: .78rem; min-width: 14rem;
}
#token:focus { outline: 1px solid var(--accent); }
main { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); margin: 1rem 0; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem .9rem; margin: 0; }
dt { color: var(--dim); font-size: .82rem; }
dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; word-break: break-all; }
dd.yes { color: var(--ok); } dd.no { color: var(--dim); }
.empty { color: var(--dim); font-style: italic; font-size: .84rem; }
.logwrap { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
#log { list-style: none; margin: 0; padding: 0; max-height: 24rem; overflow-y: auto; }
#log li {
display: grid; grid-template-columns: 7.5rem 6.5rem 1fr; gap: .6rem;
padding: .28rem 0; border-bottom: 1px solid color-mix(in srgb, var(--line) 55%, transparent);
font-size: .84rem; align-items: baseline;
}
#log li:last-child { border-bottom: 0; }
.ts { color: var(--dim); }
.ev { font-weight: 600; }
.inserted .ev { color: var(--ok); }
.removed .ev { color: var(--accent); }
.invalid .ev { color: var(--bad); }
.detail { color: var(--dim); word-break: break-all; }
.detail b { color: var(--fg); font-weight: 600; }
footer { margin-top: 1rem; color: var(--dim); font-size: .78rem; }
footer code { color: var(--fg); background: var(--panel); padding: .1rem .3rem; border-radius: 4px; }
</style>
</head>
<body>
<header>
<h1>RDM card reader</h1>
<span id="conn" class="pill">connecting&hellip;</span>
<input id="token" placeholder="token (only if the bridge asks for one)"
autocomplete="off" spellcheck="false">
</header>
<main>
<section class="panel">
<h2>Reader</h2>
<dl>
<dt>mode</dt> <dd id="mode">&mdash;</dd>
<dt>port</dt> <dd id="port">&mdash;</dd>
<dt>uptime</dt> <dd id="uptime">&mdash;</dd>
<dt>card present</dt><dd id="present">&mdash;</dd>
<dt>claimable</dt> <dd id="claimable">&mdash;</dd>
</dl>
</section>
<section class="panel">
<h2>Current card</h2>
<dl>
<dt>id</dt> <dd id="c-id">&mdash;</dd>
<dt>hex</dt> <dd id="c-hex">&mdash;</dd>
<dt>type</dt> <dd id="c-type">&mdash;</dd>
<dt>valid</dt> <dd id="c-valid">&mdash;</dd>
<dt>age</dt> <dd id="c-age">&mdash;</dd>
</dl>
<p id="c-none" class="empty">No card has been read yet.</p>
</section>
<section class="panel">
<h2>Counters</h2>
<dl>
<dt>inserted</dt> <dd id="n-inserted">&mdash;</dd>
<dt>removed</dt> <dd id="n-removed">&mdash;</dd>
<dt>invalid</dt> <dd id="n-invalid">&mdash;</dd>
<dt>subscribers</dt> <dd id="n-subs">&mdash;</dd>
<dt>tap / claimed TTL</dt><dd id="n-ttl">&mdash;</dd>
</dl>
</section>
</main>
<section class="logwrap">
<h2>Event log</h2>
<ol id="log"></ol>
<p id="log-none" class="empty">Waiting for card events&hellip;</p>
</section>
<footer>
Recent history from <code>GET /api/taps</code>, then live via
<code>GET /api/events</code> (SSE); status polled every 2s.
Read-only: this page never claims a tap. The API is documented in the README.
</footer>
<script>
(function () {
"use strict";
var REFRESH_MS = __REFRESH_MS__;
var MAX_ROWS = __MAX_ROWS__;
var el = function (id) { return document.getElementById(id); };
var tokenInput = el("token");
tokenInput.value = localStorage.getItem("rdm-token") || "";
function token() { return tokenInput.value.trim(); }
function authHeaders() {
var t = token();
return t ? { "X-Auth-Token": t } : {};
}
function setConn(up) {
var c = el("conn");
c.className = "pill " + (up ? "up" : "down");
c.textContent = up ? "live" : "disconnected";
}
function fmtDuration(seconds) {
if (seconds === null || seconds === undefined) return "\\u2014";
var s = Math.max(0, Math.round(seconds));
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
if (h) return h + "h " + m + "m";
if (m) return m + "m " + r + "s";
return r + "s";
}
// card_type is an integer (0x0F arrives as 15), so it must be re-encoded
// as hex rather than printed as-is.
function hex2(value) {
return "0x" + Number(value).toString(16).toUpperCase().padStart(2, "0");
}
function setYesNo(id, value) {
var node = el(id);
node.textContent = value ? "yes" : "no";
node.className = value ? "yes" : "no";
}
function setText(id, value) {
el(id).textContent = (value === null || value === undefined || value === "")
? "\\u2014" : String(value);
}
function renderStatus(health, card) {
setText("mode", health.mode);
setText("port", health.port || "not open");
setText("uptime", fmtDuration(health.uptime_seconds));
setYesNo("present", !!health.card_present);
setYesNo("claimable", !!(card && card.claimable));
var counts = health.counts || {};
setText("n-inserted", counts.card_inserted || 0);
setText("n-removed", counts.card_removed || 0);
setText("n-invalid", counts.invalid_card || 0);
setText("n-subs", health.subscribers);
setText("n-ttl", (health.tap_ttl_seconds || "\\u221e") + " / " +
(health.claimed_ttl_seconds || "\\u221e"));
var shown = (card && card.card) || health.last_tap;
el("c-none").hidden = !!shown;
if (shown) {
setText("c-id", shown.id);
setText("c-hex", shown.hex);
setText("c-type", hex2(shown.type));
setText("c-valid", shown.is_valid ? "yes" : "no");
setText("c-age", fmtDuration(card ? card.age_seconds : health.last_tap_age_seconds));
}
}
var KIND = { card_inserted: "inserted", card_removed: "removed", invalid_card: "invalid" };
// Guards against showing the same event twice: recent history is seeded from
// /api/taps on load, and the first SSE frames can overlap with it.
var seen = Object.create(null);
function pushLog(event) {
var key = (event.timestamp || "") + "|" + (event.event || "") + "|" + event.id;
if (seen[key]) return;
seen[key] = true;
var list = el("log");
var li = document.createElement("li");
li.className = KIND[event.event] || "";
var ts = document.createElement("span");
ts.className = "ts";
ts.textContent = event.timestamp || "";
var name = document.createElement("span");
name.className = "ev";
name.textContent = (event.event || "").replace(/_/g, " ");
var detail = document.createElement("span");
detail.className = "detail";
if (event.event === "card_removed") {
detail.innerHTML = "id " + event.id;
} else {
detail.innerHTML = "id <b>" + event.id + "</b> &middot; " + event.hex +
" &middot; " + hex2(event.type) +
(event.is_valid ? "" : " &middot; checksum mismatch");
}
li.appendChild(ts); li.appendChild(name); li.appendChild(detail);
list.insertBefore(li, list.firstChild);
while (list.childElementCount > MAX_ROWS) list.removeChild(list.lastChild);
el("log-none").hidden = true;
}
function seedLog() {
fetch("/api/taps?limit=" + MAX_ROWS, { headers: authHeaders() })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.taps) return;
// taps() is chronological, oldest first; prepending leaves newest on top.
data.taps.forEach(pushLog);
})
.catch(function () { /* history is a convenience, not required */ });
}
function onError() {
setConn(false);
}
function poll() {
Promise.all([
fetch("/api/health", { headers: authHeaders() }),
fetch("/api/card", { headers: authHeaders() })
]).then(function (responses) {
if (responses[0].status === 401 || responses[1].status === 401) {
// The bridge wants a token for this caller. Say so instead of
// reporting the reader as disconnected.
el("conn").className = "pill down";
el("conn").textContent = "token required";
return null;
}
if (!responses[0].ok) throw new Error("health " + responses[0].status);
return Promise.all([responses[0].json(), responses[1].json()]);
}).then(function (data) {
if (!data) return;
renderStatus(data[0], data[1]);
if (es && es.readyState === 1) setConn(true);
}).catch(onError);
}
var es = null;
function connectEvents() {
if (es) es.close();
var t = token();
// EventSource cannot set headers, so the token goes in the query string -
// the same mechanism the API already accepts for non-local callers.
var url = "/api/events" + (t ? "?token=" + encodeURIComponent(t) : "");
es = new EventSource(url);
es.onopen = function () { setConn(true); };
es.onmessage = function (message) {
try { pushLog(JSON.parse(message.data)); } catch (e) { /* ignore */ }
};
es.onerror = onError; // EventSource reconnects on its own
}
tokenInput.addEventListener("change", function () {
localStorage.setItem("rdm-token", token());
connectEvents();
seedLog();
poll();
});
connectEvents();
seedLog();
poll();
setInterval(poll, REFRESH_MS);
})();
</script>
</body>
</html>
"""
def render_page() -> bytes:
"""The dashboard as UTF-8 bytes, ready to write to the socket."""
page = (
PAGE_HTML
.replace("__REFRESH_MS__", str(REFRESH_MS))
.replace("__MAX_ROWS__", str(MAX_LOG_ROWS))
)
return page.encode("utf-8")