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:
@@ -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() {
|
||||
<Route path="/codex" element={<CodexPage />} />
|
||||
<Route path="/codex/:id" element={<CodexEntityPage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="/devices" element={<DevicesPage />} />
|
||||
<Route path="*" element={<LandingPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
157
frontend/src/lib/deviceFeed.test.ts
Normal file
157
frontend/src/lib/deviceFeed.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
156
frontend/src/lib/deviceFeed.ts
Normal file
156
frontend/src/lib/deviceFeed.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
446
frontend/src/pages/DevicesPage.css
Normal file
446
frontend/src/pages/DevicesPage.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
311
frontend/src/pages/DevicesPage.test.tsx
Normal file
311
frontend/src/pages/DevicesPage.test.tsx
Normal file
@@ -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<typeof import('../lib/deviceFeed')>('../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<Response>) {
|
||||
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<FakeDeviceFeedSocket> {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={['/devices']}>
|
||||
<DevicesPage />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
)
|
||||
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}')
|
||||
})
|
||||
})
|
||||
347
frontend/src/pages/DevicesPage.tsx
Normal file
347
frontend/src/pages/DevicesPage.tsx
Normal file
@@ -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<string, Reading> }
|
||||
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 (
|
||||
<div className="devices-loading" role="status">
|
||||
<p className="loading-text">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
return <DevicesSession />
|
||||
}
|
||||
|
||||
export default DevicesPage
|
||||
|
||||
// ---- the device bay itself ----
|
||||
|
||||
function DevicesSession() {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
// ---- pairing ----
|
||||
const [name, setName] = useState('')
|
||||
const [pairState, setPairState] = useState<PairState>('idle')
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
const [revealToken, setRevealToken] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// ---- live dashboard ----
|
||||
const [devices, setDevices] = useState<Record<string, DeviceRow>>({})
|
||||
const [order, setOrder] = useState<string[]>([])
|
||||
const [connection, setConnection] = useState<DeviceFeedConnectionState>('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<HTMLFormElement>) => {
|
||||
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<string, DeviceRow> = {}
|
||||
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 (
|
||||
<div className="devices-page">
|
||||
<div className="devices-overlay" aria-hidden="true" />
|
||||
|
||||
<header className="devices-header">
|
||||
<Link to="/seance" className="devices-back">
|
||||
{t('devices.back', '← the threshold')}
|
||||
</Link>
|
||||
<h1 className="devices-title">{t('devices.title', 'DEVICE BAY')}</h1>
|
||||
<p className="devices-subtitle">
|
||||
{t('devices.subtitle', 'pair hardware to your account and watch its telemetry live')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="devices-pair" aria-label={t('devices.pair.title', 'PAIR A NEW DEVICE')}>
|
||||
<h2 className="devices-section-title">
|
||||
{t('devices.pair.title', 'PAIR A NEW DEVICE')}
|
||||
</h2>
|
||||
<form className="pair-form" onSubmit={submitPair}>
|
||||
<label className="visually-hidden" htmlFor="device-name">
|
||||
{t('devices.pair.nameLabel', 'device name')}
|
||||
</label>
|
||||
<input
|
||||
id="device-name"
|
||||
className="pair-input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (nameError) setNameError(null)
|
||||
}}
|
||||
placeholder={t('devices.pair.namePlaceholder', 'e.g. attic-node-01')}
|
||||
disabled={pairState === 'pending'}
|
||||
/>
|
||||
<button className="pair-submit" type="submit" disabled={pairState === 'pending'}>
|
||||
{pairState === 'pending'
|
||||
? t('devices.pair.pending', 'pairing…')
|
||||
: t('devices.pair.submit', 'pair device')}
|
||||
</button>
|
||||
</form>
|
||||
{nameError && (
|
||||
<p className="pair-message is-error" role="alert">
|
||||
{nameError}
|
||||
</p>
|
||||
)}
|
||||
{pairState === 'error' && (
|
||||
<p className="pair-message is-error" role="alert">
|
||||
{t('devices.pair.error', 'the pairing handshake failed — try again')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{revealToken && (
|
||||
<div className="token-reveal" role="alert">
|
||||
<p className="token-reveal-warning">
|
||||
<span className="token-reveal-icon" aria-hidden="true">
|
||||
⚠
|
||||
</span>
|
||||
{t(
|
||||
'devices.token.warning',
|
||||
'copy this now — it cannot be shown again. losing it means re-pairing the device.',
|
||||
)}
|
||||
</p>
|
||||
<div className="token-reveal-box">
|
||||
<code className="token-reveal-value">{revealToken}</code>
|
||||
<button type="button" className="token-copy" onClick={copyToken}>
|
||||
{copied ? t('devices.token.copied', 'copied') : t('devices.token.copy', 'copy')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="token-confirm" onClick={dismissToken}>
|
||||
{t('devices.token.confirm', "I've saved it — hide token")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="devices-dashboard" aria-label={t('devices.dashboard.title', 'LIVE FEED')}>
|
||||
<div className="dashboard-heading">
|
||||
<h2 className="devices-section-title">{t('devices.dashboard.title', 'LIVE FEED')}</h2>
|
||||
<span
|
||||
className={`feed-dot feed-${connection}`}
|
||||
title={t(`devices.dashboard.connection.${connection}`)}
|
||||
aria-label={t(`devices.dashboard.connection.${connection}`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{order.length === 0 ? (
|
||||
<p className="devices-state">
|
||||
{t('devices.dashboard.empty', 'no devices paired yet — pair one above to see it here')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="device-grid">
|
||||
{order.map((id) => {
|
||||
const device = devices[id]
|
||||
if (!device) return null
|
||||
const sensorTypes = Object.keys(device.readings)
|
||||
return (
|
||||
<article className="device-card" key={id} data-testid={`device-card-${id}`}>
|
||||
<header className="device-card-head">
|
||||
<h3 className="device-name">{device.name}</h3>
|
||||
<p className="device-lastseen">
|
||||
{t('devices.dashboard.lastSeen', 'last seen')}:{' '}
|
||||
{device.last_seen_at
|
||||
? formatTimestamp(device.last_seen_at, i18n.language)
|
||||
: t('devices.dashboard.lastSeenNever', 'never')}
|
||||
</p>
|
||||
</header>
|
||||
{sensorTypes.length === 0 ? (
|
||||
<p className="device-noreadings">
|
||||
{t('devices.dashboard.noReadings', 'awaiting first reading…')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="sensor-grid">
|
||||
{sensorTypes.map((sensorType) => {
|
||||
const r = device.readings[sensorType]
|
||||
return (
|
||||
<div className="sensor-row" key={sensorType}>
|
||||
<span className="sensor-type">{sensorType}</span>
|
||||
<span className="sensor-value flicker-soft">
|
||||
{formatSensorValue(r.value)}{' '}
|
||||
<span className="sensor-unit">{r.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -254,6 +254,10 @@ function SeanceSession({ username }: { username: string }) {
|
||||
{t('nav.codex')}
|
||||
</Link>
|
||||
|
||||
<Link to="/devices" className="seance-codex-link">
|
||||
{t('nav.devices', { defaultValue: 'devices' })}
|
||||
</Link>
|
||||
|
||||
<span className="seance-user" title={username}>
|
||||
{username}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user