From e886c94d402673cac4a020f0bc640d4001c875c9 Mon Sep 17 00:00:00 2001 From: Indiana Date: Fri, 24 Jul 2026 01:10:24 +0000 Subject: [PATCH] feat(frontend): device pairing UI + live telemetry dashboard (Workstream H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /devices — pair an ESP32 sensor node (POST /api/device), reveal its raw pairing token exactly once with a hard-to-miss "cannot be shown again" warning (styled like a real API-key-reveal UI), then a live dashboard subscribing to /ws/device-feed: the initial `devices` frame seeds paired devices, and `reading` frames update one row per distinct sensor_type in place. sensor_type/value/unit are rendered fully generically per the contract (free-form, open-ended) — an unrecognized sensor_type renders safely with no special-casing. - frontend/src/lib/deviceFeed.ts: reconnecting WS client for /ws/device-feed, mirroring VeilSocket's backoff shape (receive-only, no outbox needed). - frontend/src/pages/DevicesPage.{tsx,css}: pairing form + one-time token reveal + live device-card grid. Leans into "hacker" terminal styling (monospace readouts, terminal device cards) over the app's usual gothic chrome, per the design spec, while keeping the existing dark/violet palette tokens from App.css. - Route + nav link wired into App.tsx / SeancePage.tsx. - i18n: new `devices.*` / `nav.devices` keys in en.json + es.json; added a coverage-check.mjs domain rule for the dynamic connection-state key, mirroring the existing `seance.connection.` rule. Tests: deviceFeed.test.ts (backoff/reconnect/frame delivery) and DevicesPage.test.tsx (empty state, name validation, one-time token reveal and dismissal, live frame updates in place without duplicating rows, multi-device/multi-sensor rendering, and a mocked unrecognized sensor_type that must not crash). Full suite: 154 passed (137 pre-existing + 17 new). `npx tsc -b` and `npm run build` both clean. Assumption (undocumented in spec): POST /api/device's JSON response shape is inferred as `{id, name, token, last_seen_at}` since the Contract section only describes the endpoint in prose. GET /api/device is intentionally not called — the live dashboard is fully seeded by /ws/device-feed's initial `devices` frame per the contract, so it's redundant for this page's scope. Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.tsx | 2 + frontend/src/i18n/coverage-check.mjs | 1 + frontend/src/i18n/en.json | 34 ++ frontend/src/i18n/es.json | 34 ++ frontend/src/lib/deviceFeed.test.ts | 157 +++++++++ frontend/src/lib/deviceFeed.ts | 156 +++++++++ frontend/src/pages/DevicesPage.css | 446 ++++++++++++++++++++++++ frontend/src/pages/DevicesPage.test.tsx | 311 +++++++++++++++++ frontend/src/pages/DevicesPage.tsx | 347 ++++++++++++++++++ frontend/src/pages/SeancePage.tsx | 4 + 10 files changed, 1492 insertions(+) create mode 100644 frontend/src/lib/deviceFeed.test.ts create mode 100644 frontend/src/lib/deviceFeed.ts create mode 100644 frontend/src/pages/DevicesPage.css create mode 100644 frontend/src/pages/DevicesPage.test.tsx create mode 100644 frontend/src/pages/DevicesPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a33a6a8..c354578 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from './state/auth' import { HauntingLayer } from './components/HauntingLayer' import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexPage } from './pages/CodexPage' +import { DevicesPage } from './pages/DevicesPage' import { EnterPage } from './pages/EnterPage' import { LandingPage } from './pages/LandingPage' import { SeancePage } from './pages/SeancePage' @@ -27,6 +28,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs index 4a5c9f0..efd7d59 100644 --- a/frontend/src/i18n/coverage-check.mjs +++ b/frontend/src/i18n/coverage-check.mjs @@ -30,6 +30,7 @@ for (const f of files) { const RARITY = ['common', 'uncommon', 'rare', 'mythic'] const RULES = [ ['seance.connection.', ['connecting', 'open', 'unstable', 'closed']], + ['devices.dashboard.connection.', ['connecting', 'open', 'unstable', 'closed']], ['seance.status.', ['attuning', 'summoning', 'gathering']], ['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message ['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']], diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 48cecee..cf04621 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -13,6 +13,7 @@ "home": "the threshold", "codex": "codex", "shop": "shop", + "devices": "devices", "seance": "séance", "enter": "enter", "logout": "depart" @@ -333,6 +334,39 @@ }, "honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder." }, + "devices": { + "back": "← the threshold", + "title": "DEVICE BAY", + "subtitle": "pair hardware to your account and watch its telemetry live", + "pair": { + "title": "PAIR A NEW DEVICE", + "nameLabel": "device name", + "namePlaceholder": "e.g. attic-node-01", + "submit": "pair device", + "pending": "pairing…", + "nameRequired": "name the device before pairing", + "error": "the pairing handshake failed — try again" + }, + "token": { + "warning": "copy this now — it cannot be shown again. losing it means re-pairing the device.", + "copy": "copy", + "copied": "copied", + "confirm": "I've saved it — hide token" + }, + "dashboard": { + "title": "LIVE FEED", + "empty": "no devices paired yet — pair one above to see it here", + "lastSeen": "last seen", + "lastSeenNever": "never", + "noReadings": "awaiting first reading…", + "connection": { + "connecting": "reaching the feed…", + "open": "feed live", + "unstable": "feed unstable…", + "closed": "feed offline" + } + } + }, "haunting": { "whisper": { "behindYou": "behind you", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 39b65ad..ce061b2 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -13,6 +13,7 @@ "home": "el umbral", "codex": "códice", "shop": "relicario", + "devices": "dispositivos", "seance": "sesión", "enter": "entrar", "logout": "partir" @@ -333,6 +334,39 @@ }, "honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte." }, + "devices": { + "back": "← el umbral", + "title": "BAHÍA DE DISPOSITIVOS", + "subtitle": "empareja hardware con tu cuenta y observa su telemetría en vivo", + "pair": { + "title": "EMPAREJAR UN NUEVO DISPOSITIVO", + "nameLabel": "nombre del dispositivo", + "namePlaceholder": "p. ej. nodo-ático-01", + "submit": "emparejar dispositivo", + "pending": "emparejando…", + "nameRequired": "nombra el dispositivo antes de emparejarlo", + "error": "el emparejamiento falló — inténtalo de nuevo" + }, + "token": { + "warning": "cópialo ahora — no volverá a mostrarse. perderlo significa volver a emparejar el dispositivo.", + "copy": "copiar", + "copied": "copiado", + "confirm": "ya lo guardé — ocultar token" + }, + "dashboard": { + "title": "TRANSMISIÓN EN VIVO", + "empty": "aún no hay dispositivos emparejados — empareja uno arriba para verlo aquí", + "lastSeen": "visto por última vez", + "lastSeenNever": "nunca", + "noReadings": "esperando la primera lectura…", + "connection": { + "connecting": "alcanzando la transmisión…", + "open": "transmisión en vivo", + "unstable": "transmisión inestable…", + "closed": "transmisión desconectada" + } + } + }, "haunting": { "whisper": { "behindYou": "detrás de ti", diff --git a/frontend/src/lib/deviceFeed.test.ts b/frontend/src/lib/deviceFeed.test.ts new file mode 100644 index 0000000..fb4514c --- /dev/null +++ b/frontend/src/lib/deviceFeed.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DeviceFeedSocket } from './deviceFeed' +import type { DeviceFeedConnectionState, DeviceFeedFrame } from './deviceFeed' + +const URL = 'ws://quantumancy.test/ws/device-feed' + +/** Minimal stand-in for the DOM WebSocket, driven manually by the tests. */ +class FakeWebSocket { + readyState = 0 // CONNECTING + onopen: (() => void) | null = null + onmessage: ((ev: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + + constructor(readonly url: string) {} + + close(): void { + this.readyState = 3 // CLOSED + this.onclose?.() + } + + // ---- test drives ---- + + serverOpen(): void { + this.readyState = 1 // WebSocket.OPEN + this.onopen?.() + } + + serverSend(data: unknown): void { + this.onmessage?.({ data }) + } + + serverClose(): void { + this.readyState = 3 + this.onclose?.() + } +} + +function makeHarness(opts: { baseBackoffMs?: number; maxBackoffMs?: number } = {}) { + const sockets: FakeWebSocket[] = [] + const socket = new DeviceFeedSocket({ + url: URL, + baseBackoffMs: opts.baseBackoffMs ?? 100, + maxBackoffMs: opts.maxBackoffMs ?? 1000, + socketFactory: (url: string): WebSocket => { + const fake = new FakeWebSocket(url) + sockets.push(fake) + return fake as unknown as WebSocket + }, + }) + return { socket, sockets } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('DeviceFeedSocket', () => { + it('starts closed and transitions through connecting -> open', () => { + const { socket, sockets } = makeHarness() + const states: DeviceFeedConnectionState[] = [] + socket.onState((s) => states.push(s)) + expect(socket.state).toBe('closed') + + socket.connect() + expect(socket.state).toBe('connecting') + + sockets[0].serverOpen() + expect(socket.state).toBe('open') + expect(states).toEqual(['connecting', 'open']) + }) + + it('delivers parsed frames to handlers', () => { + const { socket, sockets } = makeHarness() + const frames: DeviceFeedFrame[] = [] + socket.onFrame((f) => frames.push(f)) + socket.connect() + sockets[0].serverOpen() + + sockets[0].serverSend( + JSON.stringify({ + type: 'devices', + devices: [{ id: 'd1', name: 'attic-node', last_seen_at: null }], + }), + ) + sockets[0].serverSend( + JSON.stringify({ + type: 'reading', + device_id: 'd1', + sensor_type: 'temperature', + value: 21.4, + unit: 'c', + metadata: {}, + at: '2026-07-23T00:00:00Z', + }), + ) + + expect(frames).toHaveLength(2) + expect(frames[0]).toMatchObject({ type: 'devices' }) + expect(frames[1]).toMatchObject({ type: 'reading', sensor_type: 'temperature' }) + }) + + it('ignores malformed JSON without throwing', () => { + const { socket, sockets } = makeHarness() + const frames: DeviceFeedFrame[] = [] + socket.onFrame((f) => frames.push(f)) + socket.connect() + sockets[0].serverOpen() + + expect(() => sockets[0].serverSend('{not json')).not.toThrow() + expect(frames).toHaveLength(0) + }) + + it('reconnects with backoff after an unexpected close, and delivers frames on the new socket', () => { + const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 1000 }) + const states: DeviceFeedConnectionState[] = [] + socket.onState((s) => states.push(s)) + socket.connect() + sockets[0].serverOpen() + + sockets[0].serverClose() + expect(socket.state).toBe('unstable') + + vi.advanceTimersByTime(100) + expect(sockets).toHaveLength(2) + sockets[1].serverOpen() + expect(socket.state).toBe('open') + expect(states).toEqual(['connecting', 'open', 'unstable', 'connecting', 'open']) + }) + + it('does not reconnect after a deliberate close()', () => { + const { socket, sockets } = makeHarness() + socket.connect() + sockets[0].serverOpen() + socket.close() + expect(socket.state).toBe('closed') + + vi.advanceTimersByTime(5000) + expect(sockets).toHaveLength(1) + }) + + it('unsubscribes handlers returned by onFrame/onState', () => { + const { socket, sockets } = makeHarness() + const frames: DeviceFeedFrame[] = [] + const unsub = socket.onFrame((f) => frames.push(f)) + socket.connect() + sockets[0].serverOpen() + unsub() + + sockets[0].serverSend(JSON.stringify({ type: 'devices', devices: [] })) + expect(frames).toHaveLength(0) + }) +}) diff --git a/frontend/src/lib/deviceFeed.ts b/frontend/src/lib/deviceFeed.ts new file mode 100644 index 0000000..dbad6f8 --- /dev/null +++ b/frontend/src/lib/deviceFeed.ts @@ -0,0 +1,156 @@ +// Reconnecting WebSocket client for /ws/device-feed — the live instrument +// panel's data source (paired-device list + live sensor readings). +// Structurally mirrors VeilSocket (./ws.ts): same backoff/reconnect shape, +// but this feed is receive-only (the dashboard never sends client frames), +// so there's no outbox to flush. + +export type DeviceSummary = { + id: string + name: string + last_seen_at: string | null +} + +/** `value` is intentionally untyped: sensor_type is free-form by design, so + * a reading's value/unit shape is whatever the paired hardware sent — the + * frontend must render it generically rather than assume a shape. */ +export type DeviceReading = { + device_id: string + sensor_type: string + value: unknown + unit: string + metadata: Record + at: string +} + +export type DeviceFeedFrame = + | { type: 'devices'; devices: DeviceSummary[] } + | ({ type: 'reading' } & DeviceReading) + +export type DeviceFeedConnectionState = 'connecting' | 'open' | 'unstable' | 'closed' + +type FrameHandler = (frame: DeviceFeedFrame) => void +type StateHandler = (state: DeviceFeedConnectionState) => void + +export type DeviceFeedSocketOptions = { + url?: string + /** Base backoff delay (ms); doubles each retry up to maxBackoffMs. */ + baseBackoffMs?: number + maxBackoffMs?: number + /** Injectable WebSocket constructor (tests). */ + socketFactory?: (url: string) => WebSocket +} + +export function defaultDeviceFeedUrl(): string { + const proto = location.protocol === 'https:' ? 'wss://' : 'ws://' + return `${proto}${location.host}/ws/device-feed` +} + +export class DeviceFeedSocket { + private ws: WebSocket | null = null + private frameHandlers = new Set() + private stateHandlers = new Set() + private readonly url: string + private readonly baseBackoffMs: number + private readonly maxBackoffMs: number + private readonly socketFactory: (url: string) => WebSocket + private attempts = 0 + private reconnectTimer: ReturnType | null = null + private deliberatelyClosed = false + private _state: DeviceFeedConnectionState = 'closed' + + constructor(opts: DeviceFeedSocketOptions = {}) { + this.url = opts.url ?? defaultDeviceFeedUrl() + this.baseBackoffMs = opts.baseBackoffMs ?? 800 + this.maxBackoffMs = opts.maxBackoffMs ?? 15000 + this.socketFactory = opts.socketFactory ?? ((url: string) => new WebSocket(url)) + } + + get state(): DeviceFeedConnectionState { + return this._state + } + + onFrame(handler: FrameHandler): () => void { + this.frameHandlers.add(handler) + return () => this.frameHandlers.delete(handler) + } + + onState(handler: StateHandler): () => void { + this.stateHandlers.add(handler) + return () => this.stateHandlers.delete(handler) + } + + connect(): void { + this.deliberatelyClosed = false + this.openSocket() + } + + close(): void { + this.deliberatelyClosed = true + this.clearTimer() + this.ws?.close() + this.ws = null + this.setState('closed') + } + + private setState(state: DeviceFeedConnectionState): void { + if (this._state === state) return + this._state = state + this.stateHandlers.forEach((h) => h(state)) + } + + private clearTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + private openSocket(): void { + this.clearTimer() + this.setState('connecting') + let ws: WebSocket + try { + ws = this.socketFactory(this.url) + } catch { + this.scheduleReconnect() + return + } + this.ws = ws + + ws.onopen = () => { + this.attempts = 0 + this.setState('open') + } + + ws.onmessage = (ev: MessageEvent) => { + if (typeof ev.data !== 'string') return + let frame: DeviceFeedFrame + try { + frame = JSON.parse(ev.data) as DeviceFeedFrame + } catch { + return // ignore malformed frames + } + this.frameHandlers.forEach((h) => h(frame)) + } + + ws.onclose = () => { + this.clearTimer() + this.ws = null + if (!this.deliberatelyClosed) this.scheduleReconnect() + else this.setState('closed') + } + + ws.onerror = () => { + // onclose follows onerror; nothing extra to do here. + } + } + + private scheduleReconnect(): void { + this.setState('unstable') + const delay = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** this.attempts) + this.attempts++ + this.reconnectTimer = setTimeout(() => { + if (!this.deliberatelyClosed) this.openSocket() + }, delay) + } +} diff --git a/frontend/src/pages/DevicesPage.css b/frontend/src/pages/DevicesPage.css new file mode 100644 index 0000000..848e658 --- /dev/null +++ b/frontend/src/pages/DevicesPage.css @@ -0,0 +1,446 @@ +/* Device Bay — the most overtly "hacker" surface in the app: a live + instrument panel, not an occult one. Terminal readouts over the + established dark/violet palette (bg #07070d, phosphor #7cffb2, violet + #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18) — see App.css + :root tokens). Leans harder into raw monospace/CRT texture than the + gothic serif chrome used elsewhere (ShopPage/CodexPage). */ + +.devices-page { + min-height: 100vh; + width: 100%; + background: + linear-gradient(180deg, rgba(124, 255, 178, 0.04), transparent 12%), + #07070d; + color: #d6e4dc; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + padding: 2rem clamp(1rem, 4vw, 3rem) 5rem; + box-sizing: border-box; + position: relative; +} + +.devices-overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10; + background: repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.02) 0px, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 3px + ); +} + +.devices-page .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.devices-loading { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #07070d; + color: #7cffb2; + font-family: 'IBM Plex Mono', monospace; +} + +/* ---- header ---- */ + +.devices-header { + max-width: 900px; + margin: 0 auto 2.4rem; +} + +.devices-back { + display: inline-block; + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(124, 255, 178, 0.55); + text-decoration: none; + margin-bottom: 1.2rem; + transition: color 160ms ease, text-shadow 160ms ease; +} + +.devices-back:hover, +.devices-back:focus-visible { + color: #7cffb2; + text-shadow: 0 0 12px rgba(124, 255, 178, 0.7); +} + +.devices-title { + font-family: 'IBM Plex Mono', monospace; + font-weight: 700; + font-size: clamp(1.6rem, 4.4vw, 2.4rem); + letter-spacing: 0.14em; + margin: 0 0 0.5rem; + color: #eafff3; + text-shadow: 0 0 16px rgba(124, 255, 178, 0.35); +} + +.devices-title::before { + content: '> '; + color: #7cffb2; +} + +.devices-subtitle { + margin: 0; + font-size: 0.82rem; + color: rgba(216, 230, 220, 0.6); +} + +/* ---- shared section chrome ---- */ + +.devices-pair, +.devices-dashboard { + max-width: 900px; + margin: 0 auto 2.4rem; + border: 1px solid rgba(124, 255, 178, 0.18); + border-radius: 4px; + background: rgba(16, 16, 26, 0.65); + padding: 1.4rem clamp(1rem, 3vw, 1.8rem); +} + +.devices-section-title { + margin: 0 0 1rem; + font-size: 0.78rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: rgba(124, 255, 178, 0.85); +} + +.devices-state { + margin: 0; + font-size: 0.82rem; + color: rgba(216, 230, 220, 0.55); +} + +/* ---- pairing form ---- */ + +.pair-form { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + +.pair-input { + flex: 1 1 240px; + background: rgba(7, 7, 13, 0.8); + border: 1px solid rgba(124, 255, 178, 0.3); + border-radius: 3px; + color: #d6e4dc; + font-family: inherit; + font-size: 0.85rem; + padding: 0.6rem 0.8rem; +} + +.pair-input::placeholder { + color: rgba(216, 230, 220, 0.35); +} + +.pair-input:focus-visible { + outline: none; + border-color: #7cffb2; + box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.2); +} + +.pair-submit { + background: rgba(124, 255, 178, 0.1); + border: 1px solid rgba(124, 255, 178, 0.5); + border-radius: 3px; + color: #7cffb2; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0.6rem 1.1rem; + cursor: pointer; + transition: box-shadow 150ms ease, background 150ms ease; +} + +.pair-submit:hover:not(:disabled) { + background: rgba(124, 255, 178, 0.18); + box-shadow: 0 0 14px rgba(124, 255, 178, 0.3); +} + +.pair-submit:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.pair-message { + margin: 0.7rem 0 0; + font-size: 0.78rem; +} + +.pair-message.is-error { + color: #ff3b5c; +} + +/* ---- one-time token reveal: styled deliberately like a real API-key-reveal + UI — impossible to miss, impossible to mistake for a dismissible toast. */ + +.token-reveal { + margin-top: 1.1rem; + border: 1px solid rgba(255, 59, 92, 0.55); + border-radius: 4px; + background: rgba(255, 59, 92, 0.06); + padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.8rem; + animation: token-pulse 2.4s ease-in-out infinite; +} + +@keyframes token-pulse { + 0%, 100% { box-shadow: 0 0 0 rgba(255, 59, 92, 0); } + 50% { box-shadow: 0 0 22px rgba(255, 59, 92, 0.22); } +} + +.token-reveal-warning { + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #ffb3c0; + text-transform: uppercase; +} + +.token-reveal-icon { + color: #ff3b5c; + font-size: 1rem; +} + +.token-reveal-box { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + background: rgba(7, 7, 13, 0.85); + border: 1px dashed rgba(255, 59, 92, 0.4); + border-radius: 3px; + padding: 0.7rem 0.9rem; +} + +.token-reveal-value { + flex: 1 1 auto; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.86rem; + word-break: break-all; + color: #7cffb2; + text-shadow: 0 0 8px rgba(124, 255, 178, 0.4); +} + +.token-copy { + background: rgba(178, 107, 255, 0.12); + border: 1px solid rgba(178, 107, 255, 0.5); + border-radius: 3px; + color: #b26bff; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 0.4rem 0.8rem; + cursor: pointer; + white-space: nowrap; +} + +.token-copy:hover { + background: rgba(178, 107, 255, 0.22); +} + +.token-confirm { + align-self: flex-start; + background: transparent; + border: 1px solid rgba(255, 59, 92, 0.5); + border-radius: 3px; + color: #ff9caa; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.45rem 0.9rem; + cursor: pointer; +} + +.token-confirm:hover { + background: rgba(255, 59, 92, 0.1); +} + +/* ---- live dashboard ---- */ + +.dashboard-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + margin-bottom: 1rem; +} + +.dashboard-heading .devices-section-title { + margin: 0; +} + +.feed-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #4a5058; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.4); + flex: none; +} + +.feed-connecting { + background: #b26bff; + box-shadow: 0 0 8px rgba(178, 107, 255, 0.7); + animation: feed-blink 1s steps(2, start) infinite; +} + +.feed-open { + background: #7cffb2; + box-shadow: 0 0 8px rgba(124, 255, 178, 0.7); +} + +.feed-unstable { + background: #ffb347; + box-shadow: 0 0 8px rgba(255, 179, 71, 0.6); + animation: feed-blink 0.6s steps(2, start) infinite; +} + +.feed-closed { + background: #ff3b5c; + box-shadow: 0 0 8px rgba(255, 59, 92, 0.5); +} + +@keyframes feed-blink { + 50% { opacity: 0.25; } +} + +.device-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.9rem; +} + +.device-card { + border: 1px solid rgba(124, 255, 178, 0.22); + border-radius: 4px; + background: rgba(7, 7, 13, 0.7); + padding: 0.85rem 0.95rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + position: relative; +} + +.device-card::before { + content: ''; + position: absolute; + inset: 0; + border: 1px solid rgba(124, 255, 178, 0.08); + border-radius: 4px; + pointer-events: none; +} + +.device-card-head { + display: flex; + flex-direction: column; + gap: 0.15rem; + border-bottom: 1px dotted rgba(124, 255, 178, 0.18); + padding-bottom: 0.5rem; +} + +.device-name { + margin: 0; + font-size: 0.92rem; + letter-spacing: 0.04em; + color: #eafff3; +} + +.device-name::before { + content: '▣ '; + color: #b26bff; +} + +.device-lastseen { + margin: 0; + font-size: 0.68rem; + letter-spacing: 0.04em; + color: rgba(216, 230, 220, 0.5); + text-transform: uppercase; +} + +.device-noreadings { + margin: 0; + font-size: 0.76rem; + color: rgba(216, 230, 220, 0.4); + font-style: italic; +} + +.sensor-grid { + display: flex; + flex-direction: column; + gap: 0.32rem; +} + +.sensor-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.7rem; + border-bottom: 1px dotted rgba(124, 255, 178, 0.12); + padding-bottom: 0.24rem; +} + +.sensor-type { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: lowercase; + color: rgba(211, 233, 219, 0.6); +} + +.sensor-value { + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + color: #7cffb2; + text-shadow: 0 0 8px rgba(124, 255, 178, 0.45); +} + +.sensor-unit { + font-size: 0.68rem; + color: rgba(124, 255, 178, 0.55); +} + +.flicker-soft { + animation: devices-flicker-soft 4.5s linear infinite; +} + +@keyframes devices-flicker-soft { + 0%, 100% { opacity: 1; } + 95% { opacity: 1; } + 96% { opacity: 0.82; } + 97% { opacity: 1; } +} + +@media (max-width: 520px) { + .pair-form { + flex-direction: column; + align-items: stretch; + } + .device-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/DevicesPage.test.tsx b/frontend/src/pages/DevicesPage.test.tsx new file mode 100644 index 0000000..4e49b77 --- /dev/null +++ b/frontend/src/pages/DevicesPage.test.tsx @@ -0,0 +1,311 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import '../i18n' +import { AuthProvider } from '../state/auth' +import * as api from '../api' +import type { DeviceFeedFrame } from '../lib/deviceFeed' + +vi.mock('../api') + +// ---- fake DeviceFeedSocket: mirrors the FakeWebSocket-driven pattern used +// in lib/ws.test.ts, but at the DeviceFeedSocket level so component tests +// can push frames directly without a real WebSocket. ---- + +type FrameHandler = (f: DeviceFeedFrame) => void +type StateHandler = (s: string) => void + +class FakeDeviceFeedSocket { + frameHandlers: FrameHandler[] = [] + stateHandlers: StateHandler[] = [] + closed = false + + constructor() { + instances.push(this) + } + onFrame(h: FrameHandler) { + this.frameHandlers.push(h) + return () => { + this.frameHandlers = this.frameHandlers.filter((x) => x !== h) + } + } + onState(h: StateHandler) { + this.stateHandlers.push(h) + return () => { + this.stateHandlers = this.stateHandlers.filter((x) => x !== h) + } + } + connect() { + this.stateHandlers.forEach((h) => h('open')) + } + close() { + this.closed = true + } + emit(frame: DeviceFeedFrame) { + this.frameHandlers.forEach((h) => h(frame)) + } +} + +let instances: FakeDeviceFeedSocket[] = [] + +vi.mock('../lib/deviceFeed', async () => { + const actual = await vi.importActual('../lib/deviceFeed') + return { ...actual, DeviceFeedSocket: FakeDeviceFeedSocket } +}) + +// Imported after the mocks above so the module picks up the faked socket. +const { DevicesPage, formatSensorValue } = await import('./DevicesPage') + +function mockFetch(handler: (input: RequestInfo | URL) => Response | Promise) { + vi.stubGlobal('fetch', vi.fn(handler)) +} + +beforeEach(() => { + instances = [] + vi.mocked(api.me).mockResolvedValue({ id: 'u-1', username: 'seeker' }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function renderPage(): Promise { + render( + + + + + , + ) + await waitFor(() => expect(screen.getByText('DEVICE BAY')).toBeTruthy()) + await waitFor(() => expect(instances).toHaveLength(1)) + return instances[0] +} + +describe('DevicesPage — pairing', () => { + beforeEach(() => { + mockFetch(async () => new Response('{}', { status: 200 })) + }) + + it('shows the empty dashboard state before any device is paired', async () => { + await renderPage() + expect(screen.getByText(/no devices paired yet/i)).toBeTruthy() + }) + + it('blocks submission and shows a validation message when the name is empty', async () => { + await renderPage() + await userEvent.click(screen.getByRole('button', { name: /pair device/i })) + expect(screen.getByText(/name the device/i)).toBeTruthy() + // No pairing request should have gone out. + expect(vi.mocked(fetch)).not.toHaveBeenCalledWith( + expect.stringContaining('/api/device'), + expect.anything(), + ) + }) + + it('shows the raw token exactly once with a hard-to-miss warning, then hides it once dismissed', async () => { + mockFetch(async (input) => { + const url = String(input) + if (url.includes('/api/device')) { + return new Response( + JSON.stringify({ + id: 'd-1', + name: 'attic-node', + token: 'raw-token-xyz-do-not-lose-me', + last_seen_at: null, + }), + { status: 201, headers: { 'Content-Type': 'application/json' } }, + ) + } + return new Response('{}', { status: 200 }) + }) + await renderPage() + + await userEvent.type(screen.getByLabelText(/device name/i), 'attic-node') + await userEvent.click(screen.getByRole('button', { name: /pair device/i })) + + await waitFor(() => expect(screen.getByText('raw-token-xyz-do-not-lose-me')).toBeTruthy()) + expect(screen.getByText(/cannot be shown again/i)).toBeTruthy() + + // The paired device should already be visible on the dashboard without + // waiting for a WS "devices" frame round-trip. + expect(screen.getByText('attic-node')).toBeTruthy() + + await userEvent.click(screen.getByRole('button', { name: /saved it/i })) + expect(screen.queryByText('raw-token-xyz-do-not-lose-me')).toBeNull() + }) + + it('shows an error message when the pairing request fails', async () => { + mockFetch(async () => new Response('{}', { status: 500 })) + await renderPage() + await userEvent.type(screen.getByLabelText(/device name/i), 'attic-node') + await userEvent.click(screen.getByRole('button', { name: /pair device/i })) + await waitFor(() => expect(screen.getByText(/pairing handshake failed/i)).toBeTruthy()) + }) +}) + +describe('DevicesPage — live dashboard', () => { + beforeEach(() => { + mockFetch(async () => new Response('{}', { status: 200 })) + }) + + it('lists paired devices from the initial "devices" frame', async () => { + const socket = await renderPage() + act(() => { + socket.emit({ + type: 'devices', + devices: [ + { id: 'd-1', name: 'attic-node', last_seen_at: '2026-07-23T10:00:00Z' }, + { id: 'd-2', name: 'basement-node', last_seen_at: null }, + ], + }) + }) + expect(screen.getByText('attic-node')).toBeTruthy() + expect(screen.getByText('basement-node')).toBeTruthy() + }) + + it('updates a sensor reading in place as new frames arrive, without duplicating the row', async () => { + const socket = await renderPage() + act(() => { + socket.emit({ + type: 'devices', + devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }], + }) + }) + act(() => { + socket.emit({ + type: 'reading', + device_id: 'd-1', + sensor_type: 'temperature', + value: 21.4, + unit: 'c', + metadata: {}, + at: '2026-07-23T10:00:01Z', + }) + }) + expect(screen.getByText('21.4')).toBeTruthy() + expect(screen.getAllByText('temperature')).toHaveLength(1) + + act(() => { + socket.emit({ + type: 'reading', + device_id: 'd-1', + sensor_type: 'temperature', + value: 22.1, + unit: 'c', + metadata: {}, + at: '2026-07-23T10:00:05Z', + }) + }) + expect(screen.queryByText('21.4')).toBeNull() + expect(screen.getByText('22.1')).toBeTruthy() + expect(screen.getAllByText('temperature')).toHaveLength(1) + }) + + it('renders one row per distinct sensor_type, across multiple devices', async () => { + const socket = await renderPage() + act(() => { + socket.emit({ + type: 'devices', + devices: [ + { id: 'd-1', name: 'attic-node', last_seen_at: null }, + { id: 'd-2', name: 'basement-node', last_seen_at: null }, + ], + }) + socket.emit({ + type: 'reading', + device_id: 'd-1', + sensor_type: 'temperature', + value: 21.4, + unit: 'c', + metadata: {}, + at: 'x', + }) + socket.emit({ + type: 'reading', + device_id: 'd-1', + sensor_type: 'humidity', + value: 47.2, + unit: 'pct', + metadata: {}, + at: 'x', + }) + socket.emit({ + type: 'reading', + device_id: 'd-2', + sensor_type: 'presence', + value: 1, + unit: 'bool', + metadata: {}, + at: 'x', + }) + }) + expect(screen.getByText('temperature')).toBeTruthy() + expect(screen.getByText('humidity')).toBeTruthy() + expect(screen.getByText('presence')).toBeTruthy() + }) + + it('renders an unrecognized sensor_type safely — raw value + unit, no special-casing, no crash', async () => { + const socket = await renderPage() + act(() => { + socket.emit({ + type: 'devices', + devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }], + }) + }) + expect(() => { + act(() => { + socket.emit({ + type: 'reading', + device_id: 'd-1', + sensor_type: 'ectoplasm_flux', + value: { channels: [1, 2, 3] }, + unit: 'quanta', + metadata: {}, + at: 'x', + }) + }) + }).not.toThrow() + expect(screen.getByText('ectoplasm_flux')).toBeTruthy() + expect(screen.getByText(/quanta/)).toBeTruthy() + }) + + it('creates a device row for a reading whose device was not in the initial list, without crashing', async () => { + const socket = await renderPage() + act(() => { + socket.emit({ type: 'devices', devices: [] }) + }) + expect(() => { + act(() => { + socket.emit({ + type: 'reading', + device_id: 'mystery-device', + sensor_type: 'presence', + value: true, + unit: 'bool', + metadata: {}, + at: 'x', + }) + }) + }).not.toThrow() + expect(screen.getByText('presence')).toBeTruthy() + }) +}) + +describe('formatSensorValue', () => { + it('formats numbers, trimming float noise without hardcoding decimal places', () => { + expect(formatSensorValue(21.4)).toBe('21.4') + expect(formatSensorValue(1013.2)).toBe('1013.2') + expect(formatSensorValue(1)).toBe('1') + }) + + it('formats booleans, null/undefined, arrays and plain objects without throwing', () => { + expect(formatSensorValue(true)).toBe('1') + expect(formatSensorValue(false)).toBe('0') + expect(formatSensorValue(null)).toBe('—') + expect(formatSensorValue(undefined)).toBe('—') + expect(formatSensorValue([1, 2, 3])).toBe('[1, 2, 3]') + expect(formatSensorValue({ a: 1 })).toBe('{"a":1}') + }) +}) diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx new file mode 100644 index 0000000..ef3ce69 --- /dev/null +++ b/frontend/src/pages/DevicesPage.tsx @@ -0,0 +1,347 @@ +// The Device Bay — pair ESP32 sensor nodes to your account and watch their +// telemetry live. The most overtly "hacker" surface in the app: this reads +// as a real instrument panel, not an occult one — terminal readouts, raw +// device tokens, packet-by-packet live updates. +// +// Pairing (POST /api/device) follows the same one-time-secret convention as +// a cloud API-key reveal: the raw token is shown exactly once, with a +// hard-to-miss warning, and is never retrievable again once dismissed — +// losing it means re-pairing the device (see the design spec's Contract +// section: the backend never stores or returns the raw token again). +// +// The live dashboard subscribes to /ws/device-feed. On connect the server +// sends the full device list (`{"type":"devices", ...}`); after that, +// `{"type":"reading", ...}` frames update each device's per-sensor_type row +// in place. sensor_type is free-form by design (the whole point of the +// hardware line, per the spec), so rendering is fully generic — an +// unrecognized sensor_type renders its raw value + unit with no +// special-casing, and can never crash the page. + +import { useCallback, useEffect, useState } from 'react' +import type { FormEvent } from 'react' +import { Link, Navigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useAuth } from '../state/auth' +import { DeviceFeedSocket } from '../lib/deviceFeed' +import type { DeviceFeedConnectionState, DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed' +import './DevicesPage.css' + +type Reading = { value: unknown; unit: string; at: string } +type DeviceRow = DeviceSummary & { readings: Record } +type PairState = 'idle' | 'pending' | 'error' + +type PairResponse = { + id: string + name: string + token: string + last_seen_at?: string | null +} + +/** + * Render a raw sensor value generically. sensor_type/value/unit are + * open-ended by contract — this must never assume a shape, and must never + * throw regardless of what an unrecognized sensor sends. + */ +export function formatSensorValue(value: unknown): string { + if (typeof value === 'number') { + if (!Number.isFinite(value)) return String(value) + // Trim float noise without hardcoding a fixed decimal count per unit. + return String(Number(value.toFixed(3))) + } + if (typeof value === 'boolean') return value ? '1' : '0' + if (value === null || value === undefined) return '—' + if (Array.isArray(value)) return `[${value.map(formatSensorValue).join(', ')}]` + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch { + return '[object]' + } + } + return String(value) +} + +function formatTimestamp(iso: string, locale: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(date) +} + +export function DevicesPage() { + const { user, checking } = useAuth() + const { t } = useTranslation() + + if (checking) { + return ( +
+

{t('common.loading')}

+
+ ) + } + if (!user) return + + return +} + +export default DevicesPage + +// ---- the device bay itself ---- + +function DevicesSession() { + const { t, i18n } = useTranslation() + + // ---- pairing ---- + const [name, setName] = useState('') + const [pairState, setPairState] = useState('idle') + const [nameError, setNameError] = useState(null) + const [revealToken, setRevealToken] = useState(null) + const [copied, setCopied] = useState(false) + + // ---- live dashboard ---- + const [devices, setDevices] = useState>({}) + const [order, setOrder] = useState([]) + const [connection, setConnection] = useState('connecting') + + const upsertPairedDevice = useCallback((summary: DeviceSummary) => { + setDevices((prev) => ({ + ...prev, + [summary.id]: { ...summary, readings: prev[summary.id]?.readings ?? {} }, + })) + setOrder((prev) => (prev.includes(summary.id) ? prev : [...prev, summary.id])) + }, []) + + const submitPair = (e: FormEvent) => { + e.preventDefault() + if (pairState === 'pending') return + const trimmed = name.trim() + if (!trimmed) { + setNameError(t('devices.pair.nameRequired', 'name the device before pairing')) + return + } + setNameError(null) + setPairState('pending') + fetch('/api/device', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmed }), + }) + .then(async (res) => { + if (!res.ok) throw new Error(`pairing failed: ${res.status}`) + return (await res.json()) as PairResponse + }) + .then((device) => { + setPairState('idle') + setName('') + setCopied(false) + setRevealToken(device.token) + upsertPairedDevice({ + id: device.id, + name: device.name, + last_seen_at: device.last_seen_at ?? null, + }) + }) + .catch(() => setPairState('error')) + } + + const copyToken = () => { + if (!revealToken) return + const clipboard = navigator.clipboard + if (!clipboard?.writeText) return + clipboard.writeText(revealToken).then( + () => setCopied(true), + () => undefined, + ) + } + + const dismissToken = () => { + setRevealToken(null) + setCopied(false) + } + + useEffect(() => { + const socket = new DeviceFeedSocket() + const offFrame = socket.onFrame((frame: DeviceFeedFrame) => { + if (frame.type === 'devices') { + setDevices((prev) => { + const next: Record = {} + for (const d of frame.devices) { + next[d.id] = { ...d, readings: prev[d.id]?.readings ?? {} } + } + return next + }) + setOrder(frame.devices.map((d) => d.id)) + return + } + // frame.type === 'reading' + setDevices((prev) => { + const existing = prev[frame.device_id] + const base: DeviceRow = existing ?? { + id: frame.device_id, + name: frame.device_id, + last_seen_at: null, + readings: {}, + } + return { + ...prev, + [frame.device_id]: { + ...base, + last_seen_at: frame.at, + readings: { + ...base.readings, + [frame.sensor_type]: { value: frame.value, unit: frame.unit, at: frame.at }, + }, + }, + } + }) + setOrder((prev) => (prev.includes(frame.device_id) ? prev : [...prev, frame.device_id])) + }) + const offState = socket.onState(setConnection) + socket.connect() + return () => { + offFrame() + offState() + socket.close() + } + }, []) + + return ( +
+ + ) +} diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index 500be59..0b475e7 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -254,6 +254,10 @@ function SeanceSession({ username }: { username: string }) { {t('nav.codex')} + + {t('nav.devices', { defaultValue: 'devices' })} + + {username}