Skip to content

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.rows and state.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-density on <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.source is not board; 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:

  1. Passkey. Touch ID or Face ID on a device you enrolled. The gate shows "Sign in with passkey" as soon as one passkey exists.
  2. Owner token. Authorization: Bearer, ?token=, or the q_session cookie set by GET|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.
  3. 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 signed pk. value in the same q_session cookie, 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.my through the tunnel, localhost on 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

Run it off the board·

make install
make fake      # Q_FAKE=1 Q_HOME=/tmp/q-fake, http://127.0.0.1:8095
curl -s http://127.0.0.1:8095/api/health