feat(frontend): device pairing UI + live telemetry dashboard (Workstream H)

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 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-24 01:10:24 +00:00
parent cf817e5241
commit e886c94d40
10 changed files with 1492 additions and 0 deletions

View File

@@ -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)
})
})

View File

@@ -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<string, unknown>
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<FrameHandler>()
private stateHandlers = new Set<StateHandler>()
private readonly url: string
private readonly baseBackoffMs: number
private readonly maxBackoffMs: number
private readonly socketFactory: (url: string) => WebSocket
private attempts = 0
private reconnectTimer: ReturnType<typeof setTimeout> | 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)
}
}