import { FormEvent, useCallback, useEffect, useState } from 'react'; import Layout from '../../components/Layout'; import { api } from '../../lib/api'; import { useRequireAuth } from '../../lib/auth-context'; interface DeviceRow { id: string; name: string; serialNumber: string | null; mqttUsername: string | null; isActive: boolean; disabledReason: string | null; lastSeenAt: string | null; } export default function DevicesPage() { const { user, activeOrg, loading } = useRequireAuth(); const orgId = activeOrg?.org.id ?? null; const isAdmin = activeOrg?.role === 'ORG_ADMIN'; const [devices, setDevices] = useState([]); const [form, setForm] = useState({ name: '', mqttUsername: '', serialNumber: '' }); const [disablingId, setDisablingId] = useState(null); const [disableReason, setDisableReason] = useState(''); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const reload = useCallback(() => { if (!orgId) { return; } api .get(`/api/orgs/${orgId}/devices`) .then((rows) => { setDevices(rows); setError(null); }) .catch((err) => setError(err.message)); }, [orgId]); useEffect(reload, [reload]); const addDevice = async (e: FormEvent) => { e.preventDefault(); try { const created = await api.post( `/api/orgs/${orgId}/devices`, { name: form.name, mqttUsername: form.mqttUsername || undefined, serialNumber: form.serialNumber || undefined, }, ); setNotice( created.provisioning.pointsTopic ? `Device created. It should publish points to ${created.provisioning.pointsTopic}` : created.provisioning.note, ); setForm({ name: '', mqttUsername: '', serialNumber: '' }); reload(); } catch (err: any) { setError(err.message); } }; const enable = async (deviceId: string) => { try { await api.patch(`/api/orgs/${orgId}/devices/${deviceId}`, { isActive: true }); reload(); } catch (err: any) { setError(err.message); } }; const confirmDisable = async (deviceId: string) => { try { await api.patch(`/api/orgs/${orgId}/devices/${deviceId}`, { isActive: false, disabledReason: disableReason || undefined, }); setDisablingId(null); setDisableReason(''); reload(); } catch (err: any) { setError(err.message); } }; const remove = async (deviceId: string) => { try { await api.delete(`/api/orgs/${orgId}/devices/${deviceId}`); reload(); } catch (err: any) { setError(err.message); } }; if (loading || !user) { return null; } return (

Devices

Field devices publish to devices/<mqtt username>/points. Broker credentials are provisioned separately for now. A disabled device can check GET /api/devices/<serial>/status for its disabled state and reason.

{error &&

{error}

} {notice &&

{notice}

} {isAdmin && (
setForm((f) => ({ ...f, name: e.target.value }))} required style={{ padding: '0.4rem' }} /> setForm((f) => ({ ...f, serialNumber: e.target.value }))} style={{ padding: '0.4rem' }} /> setForm((f) => ({ ...f, mqttUsername: e.target.value }))} style={{ padding: '0.4rem' }} />
)} {isAdmin && {devices.map((d) => ( {isAdmin && ( )} ))} {isAdmin && disablingId && devices.some((d) => d.id === disablingId) && ( )}
Name MQTT username Serial Status Last seen}
{d.name} {d.mqttUsername ? {d.mqttUsername} : '—'} {d.serialNumber ?? '—'} {d.isActive ? 'active' : 'disabled'} {!d.isActive && d.disabledReason && (
{d.disabledReason}
)}
{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'} {d.isActive ? ( ) : ( )}{' '}
{ e.preventDefault(); confirmDisable(disablingId); }} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }} > Reason for disabling {devices.find((d) => d.id === disablingId)?.name}: setDisableReason(e.target.value)} style={{ padding: '0.4rem', flex: 1 }} />
); }