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