Dashboard·
A FastAPI server on the board at `127.0.0.1:8095`, published as https://q.cagatay.my by a cloudflared tunnel running on the board. Chat first, telemetry beside it, one screen on a phone.
Where it stands·
| state | |
|---|---|
| server, auth, fake board, SSE, chat, LED and MCU routes | implemented dashboard/ on main, 19 tests green off-board (Q_FAKE=1); every route and shape on this page was re-run against main by the DOCS lane on 2026-09-09 |
| running on the UNO Q with real telemetry | not yet waits for the q package and the systemd unit |
| reachable at q.cagatay.my | verified health 200 at the edge; /api/state, /api/telemetry, / return 401 without a key |
| real board readings | verified source: "board" live on 2026-09-09 (BOARD e463df1); matrix pending the q sketch |
| frontend: chat column, vitals, LED matrix editor, feed, settings, ⌘K, toasts | implemented vanilla JS, no build step; axe 0 violations at 390×844 and 1280×800 (DASH a3637fc) |
Frontend·
One screen, phone first. Plain HTML, CSS and JavaScript under dashboard/static/, no build step.
- Header with a live dot and vitals pills: temperature, load, memory, disk, Wi-Fi, uptime, MCU link, tunnel. Pills colour on warn and bad thresholds.
- Chat column as the primary control. The NDJSON stream renders tool calls in words.
- Board column: LED matrix editor sized from
state.led.rowsandstate.led.cols(toggle buttons, arrow-key navigation, text or drawing or off), MCU card. - Activity feed from the SSE stream. Settings dialog for theme and density (
data-theme,data-densityon<html>) and sign out. ⌘K palette. Toasts on MCU, tunnel and source changes. Tab list on phones. PWA manifest. - A "simulated" badge in the header whenever
state.sourceis notboard; its title carries the adapter's error, if any (DASH 904bec0). - Unauthenticated
GET /is a 401 gate page with a token form and no data in it.
Accessibility gate: tests/dashboard/browser/a11y.mjs runs Playwright with axe-core against the fake board, logs in through the real form, checks the 401 gate, landmarks, SSE-populated vitals, 104 cells in 13 columns, no horizontal overflow, a pixel round-trip through the API, ⌘K, the chat error path with Q_AGENT=off, and zero console errors. Result on 2026-09-09: 0 violations at 390×844 and 1280×800.
Sign in·
Three ways past the gate, checked in this order:
- Passkey. Touch ID or Face ID on a device you enrolled. The gate shows "Sign in with passkey" as soon as one passkey exists.
- Owner token.
Authorization: Bearer,?token=, or theq_sessioncookie set byGET|POST /api/auth/login?token=. Kept as the fallback behind a disclosure on the gate. The cookie holds an HMAC of the token, never the token itself; HttpOnly, SameSite=Lax, 30 days. - Owner's tiny session. Bearer, verified upstream at tiny.technology. This is how the iOS panel gets in.
Loopback requests are accepted only when no token exists on the board.
Everything except /api/health, /api/auth/login, /api/auth/me and the passkey login endpoints is gated. GET / without a key returns a 401 gate page with no data in it. Fail closed. Tests cover gating for every route, cookie login and logout, forged cookies and the no-token case.
Enrolling a passkey·
- Open the gate, expand Enroll this device, paste the enrollment token and name the device. The token lives on the board at
$Q_HOME/reg_token(0600, generated when the dashboard starts) and never in the repository. - Already signed in (token, tiny session or another passkey)? Settings → Add a passkey enrolls the current device without the token and keeps your existing session.
- Passkeys are stored on the board in
$Q_HOME/passkeys.json(0600). A passkey session is a signedpk.value in the sameq_sessioncookie, so revoking a passkey in Settings signs out only that device, and a dashboard restart keeps you signed in. - The relying party is the host you typed (
q.cagatay.mythrough the tunnel,localhoston the bench). A raw IP address is refused by the browser standard, so enrol over a hostname.
State: deployed to the board from main (AUTH lane, 20:39Z) and read live at 20:43Z: /api/auth/passkey/status on https://q.cagatay.my reports rp_id q.cagatay.my, enroll reg_token, 0 passkeys; the gate shows "Sign in with passkey", "Enroll this device" and "Use the owner token instead". 68 tests. No passkey is enrolled yet; that step is the owner's, at the keyboard.
Routes·
| route | body / query | returns |
|---|---|---|
GET /api/health |
{ok, name, version, source: board\|fake, uptime_s, auth, t} source follows the adapters since c9587b4; board with the q package deployed |
|
GET /api/auth/me |
{via: token\|session\|tiny\|loopback\|null, owner} |
|
GET\|POST /api/auth/login |
?token= |
303 to / with the cookie; with Accept: application/json, {ok, via} |
POST /api/auth/logout |
clears the cookie | |
GET /api/state |
full telemetry, see below | |
GET /api/telemetry |
flat JSON for the iOS and tiny panels: t source hostname uptime_s load1 cpu_temp_c mem_used_mb mem_total_mb disk_used_gb disk_total_gb wifi_ssid wifi_rssi_dbm ip mcu_link mcu_fw led_mode led_text tunnel_up plus mcu_sketch led_applied disk_free_gb home_free_gb usb_role usb_hub usb_devices ethernet doctor_ok doctor_total doctor_failing mcu_uptime_s |
|
GET /api/events |
SSE: state every Q_TICK_S seconds (default 2), plus led, gpio, mcu, agent, auth, error. First frame is the current state. |
|
POST /api/chat |
{prompt} |
NDJSON frames text, tool, tool_result, ping, error, done. With Accept: application/json, {result, tools, errors}, the tiny endpoint shape. 80 s budget. |
POST /api/led |
{text}, {frame: [[0/1 × cols] × rows]}, {glyph: ok\|x\|wifi\|tunnel\|heart\|q\|dot\|status}, {rgb: [r,g,b]} or {mode: "off"} |
the led object |
GET /api/notes · POST /api/notes · DELETE /api/notes/{id} |
?limit&tag&q · {text, tags[]} |
Q's notebook, ~/.q/notes.json |
GET /api/mcu |
{link, port, fw, resets, source} |
|
POST /api/mcu/reset |
{ok, ...} |
|
GET /api/pins |
every header pin {pins: {D2: {pin, name, alias, mode, mode_name, level, t}, … D13}, aliases: {laser: 2}, range: [2, 13], reserved: ["D0", "D1"]}; untouched pins carry mode: null |
|
GET /api/pins/{pin} |
{pin} = 2, D2 or an alias |
one pin record as this process knows it |
PUT /api/pins/{pin} |
{mode?: in\|out\|pullup, level?: 0\|1} |
the pin record after the change; a write makes the pin an output by itself; idempotent. 422 for an unknown pin, D0/D1 or a bad mode, 503 when the firmware refuses. SSE gpio {via, pin, name, alias, mode, level} |
POST /api/pin |
{pin, level\|mode\|read} |
compatibility shim over PUT /api/pins/{pin} |
State·
{"t": 1757443261.1, "source": "board|fake", "hostname": "uno-q", "model": "…", "uptime_s": 4521,
"cpu_temp_c": 46.2, "load": [0.35, 0.31, 0.28],
"mem": {"total_mb": 3789, "used_mb": 912}, "disk": {"total_gb": 10.4, "used_gb": 7.3, "free_gb": 2.6, "home_free_gb": 17.6},
"usb": {"role": "device|host|none", "udc_state": "configured", "hub": false, "devices": [], "ethernet": null, "block": []},
"doctor": {"t": 1757443261.0, "ok": 17, "total": 18, "skipped": 0, "failing": ["…"]},
"wifi": {"ssid": "…", "rssi_dbm": -58, "ip": "192.168.1.210"},
"mcu": {"link": true, "port": "/dev/ttyHS1", "fw": "…", "last_seen": 1757443261.0},
"led": {"mode": "off|text|frame", "rows": 8, "cols": 13, "frame": [[0, "…"]], "text": null},
"tunnel": {"up": true, "host": "q.cagatay.my"}, "docker": {"available": true, "running": 0}}
The frontend reads led.rows and led.cols from state and never hardcodes them. The UNO Q matrix is 8 rows by 13 columns, confirmed by BOARD from Arduino_LED_Matrix.h (canvasWidth = 13, canvasHeight = 8, a frame is uint32_t[4]); the library also supports per-pixel RGB and Font_5x7 scrolling text. An earlier "12 wide" figure in AGENTS.md was wrong.
The dashboard consumes the q package through dashboard/board.py (q.board.state(), q.led.*, q.mcu.*, q.agent.tools()). Anything missing falls back to the simulator and reports source: "fake", so the panel never pretends a fake reading is the board.
State also carries additive keys from the real adapters (BOARD e463df1): thermal{zone: c}, services{unit: bool}, led.applied, led.error, led.rgb, mcu.sketch, mcu.socket, disk.free_gb, wifi.signal_pct, wifi.rssi_source. The frontend may show led.applied: false as "matrix: no sketch".
Environment·
| key | default | meaning |
|---|---|---|
Q_TOKEN |
owner token; otherwise read from $Q_HOME/token |
|
Q_HOME |
~/.q |
token file directory |
Q_FAKE (alias Q_SIM) |
1 = simulated board |
|
Q_AGENT |
on |
off = /api/chat answers with an error frame |
Q_MODEL |
strands default | model id for the agent |
Q_TICK_S |
2 |
state tick period on /api/events |
Q_HOST / Q_PORT |
127.0.0.1 / 8095 |
bind for python -m dashboard.server |
Q_CHAT_BUDGET_S |
80 |
tiny waits 90 s |
Q_LED_BOOT |
status |
glyph drawn on the matrix once q-dash is up and the MCU is linked (status, any static glyph name, or off to leave the glass alone) |
Q_LED_STATUS_EVERY_S |
30 |
while the glass still shows the status glyph, redraw it when Wi-Fi / tunnel / doctor changed; anything else drawn ends the refresh until the next start |