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:
@@ -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`)
|
||||
> 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
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/` | Status page (unless `--no-page`) |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
```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)
|
||||
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
|
||||
ports.py stable serial port identification
|
||||
bridge.py the HTTP bridge behind --serve
|
||||
webui.py the status page served at GET / by the bridge
|
||||
led.py status LED
|
||||
bootstrap.py offline dependency install
|
||||
wheels/ the bundled wheels (offline install source)
|
||||
|
||||
@@ -55,8 +55,10 @@ import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
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 .webui import render_page
|
||||
|
||||
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
||||
#: attempt). ``monitor`` publishes inserts and removals both.
|
||||
@@ -325,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
|
||||
# -- 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:
|
||||
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
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", [])
|
||||
if not allowed:
|
||||
return False # browsers only; opt out entirely
|
||||
return origin in allowed
|
||||
|
||||
def _token_ok(self) -> bool:
|
||||
@@ -351,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
supplied = pair.split("=", 1)[1]
|
||||
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:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
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:
|
||||
self.send_response(204)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", "0")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
@@ -388,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
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":
|
||||
self._send_json(self._hub.health())
|
||||
return
|
||||
@@ -476,10 +531,10 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send_json({"mode": mode})
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
origin = self.headers.get("Origin")
|
||||
self.send_response(204)
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_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-Headers", "Content-Type, X-Auth-Token")
|
||||
self.send_header("Vary", "Origin")
|
||||
@@ -495,9 +550,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
allowed = self._allowed_origin()
|
||||
if allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", allowed)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
@@ -532,11 +587,13 @@ def create_server(
|
||||
allowed_origins: Optional[List[str]] = None,
|
||||
token: Optional[str] = None,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
serve_page: bool = True,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build (but do not start) the HTTP server around *hub*."""
|
||||
server = ThreadingHTTPServer((host, port), _Handler)
|
||||
server.daemon_threads = True
|
||||
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.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
||||
server.logger = logger # type: ignore[attr-defined]
|
||||
|
||||
@@ -212,6 +212,7 @@ def _run_bridge(args) -> int:
|
||||
allowed_origins=args.allow_origin,
|
||||
token=args.token,
|
||||
logger=print if args.verbose else None,
|
||||
serve_page=not args.no_page,
|
||||
)
|
||||
|
||||
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(
|
||||
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(" POST /api/card/claim (single use, for one login)")
|
||||
print(" GET /api/events (SSE stream)")
|
||||
@@ -303,6 +306,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"(or set RDM_BRIDGE_TOKEN)")
|
||||
bridge.add_argument("--allow-remote", action="store_true",
|
||||
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",
|
||||
help="also print each event and each HTTP request")
|
||||
|
||||
|
||||
@@ -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…</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">—</dd>
|
||||
<dt>port</dt> <dd id="port">—</dd>
|
||||
<dt>uptime</dt> <dd id="uptime">—</dd>
|
||||
<dt>card present</dt><dd id="present">—</dd>
|
||||
<dt>claimable</dt> <dd id="claimable">—</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Current card</h2>
|
||||
<dl>
|
||||
<dt>id</dt> <dd id="c-id">—</dd>
|
||||
<dt>hex</dt> <dd id="c-hex">—</dd>
|
||||
<dt>type</dt> <dd id="c-type">—</dd>
|
||||
<dt>valid</dt> <dd id="c-valid">—</dd>
|
||||
<dt>age</dt> <dd id="c-age">—</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">—</dd>
|
||||
<dt>removed</dt> <dd id="n-removed">—</dd>
|
||||
<dt>invalid</dt> <dd id="n-invalid">—</dd>
|
||||
<dt>subscribers</dt> <dd id="n-subs">—</dd>
|
||||
<dt>tap / claimed TTL</dt><dd id="n-ttl">—</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…</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> · " + event.hex +
|
||||
" · " + hex2(event.type) +
|
||||
(event.is_valid ? "" : " · 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")
|
||||
Reference in New Issue
Block a user