import { Fragment, FormEvent, useCallback, useEffect, useState } from 'react'; import Layout from '../../components/Layout'; import JobMap from '../../components/map/JobMap'; import type { MapPoint } from '../../components/map/types'; import { api } from '../../lib/api'; import { useRequireAuth } from '../../lib/auth-context'; import { useDevicesStream } from '../../lib/use-devices-stream'; import { formatRelativeTime } from '../../lib/format'; interface DeviceRow { id: string; name: string; serialNumber: string | null; mqttUsername: string | null; isActive: boolean; disabledReason: string | null; lastSeenAt: string | null; } interface LocationResult { point: MapPoint | null; job: { id: string; ticketNumber: string; title: string } | null; } interface CertInfo { id: string; serialNumber: string; commonName: string; fingerprint: string; issuedAt: string; expiresAt: string; } async function downloadText(path: string, filename: string) { const res = await fetch(path); if (!res.ok) { throw new Error(`Download failed (${res.status})`); } const text = await res.text(); const blob = new Blob([text], { type: 'application/x-pem-file' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } 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 [locationDeviceId, setLocationDeviceId] = useState(null); const [locationResult, setLocationResult] = useState(null); const [locationLoading, setLocationLoading] = useState(false); const [certDeviceId, setCertDeviceId] = useState(null); const [certInfo, setCertInfo] = useState(null); const [certLoading, setCertLoading] = useState(false); const [certBusy, setCertBusy] = useState(false); // Forces "last contact" strings to re-render periodically without refetching devices. const [, setTick] = useState(0); useEffect(() => { const timer = setInterval(() => setTick((t) => t + 1), 30_000); return () => clearInterval(timer); }, []); 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]); useDevicesStream(orgId, (evt) => { setDevices((rows) => rows.map((d) => d.id === evt.deviceId ? { ...d, lastSeenAt: evt.lastSeenAt ?? d.lastSeenAt, isActive: evt.isActive ?? d.isActive, disabledReason: evt.isActive !== undefined ? evt.disabledReason ?? null : d.disabledReason, } : d, ), ); if (evt.position && evt.deviceId === locationDeviceId) { setLocationResult({ point: evt.position, job: evt.job ?? null }); } }); 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); } }; const toggleLocation = async (deviceId: string) => { if (locationDeviceId === deviceId) { setLocationDeviceId(null); setLocationResult(null); return; } setLocationDeviceId(deviceId); setLocationResult(null); setLocationLoading(true); try { const result = await api.get(`/api/orgs/${orgId}/devices/${deviceId}/location`); setLocationResult(result); } catch (err: any) { setError(err.message); } finally { setLocationLoading(false); } }; const toggleCert = async (deviceId: string) => { if (certDeviceId === deviceId) { setCertDeviceId(null); setCertInfo(null); return; } setCertDeviceId(deviceId); setCertInfo(null); setCertLoading(true); try { const result = await api.get(`/api/orgs/${orgId}/devices/${deviceId}/certificate`); setCertInfo(result); } catch (err: any) { if (err.status !== 404) { setError(err.message); } setCertInfo(null); } finally { setCertLoading(false); } }; const issueCert = async (deviceId: string) => { setCertBusy(true); try { const result = await api.post(`/api/orgs/${orgId}/devices/${deviceId}/certificate`, {}); setCertInfo(result); } catch (err: any) { setError(err.message); } finally { setCertBusy(false); } }; const revokeCert = async (deviceId: string) => { setCertBusy(true); try { await api.delete(`/api/orgs/${orgId}/devices/${deviceId}/certificate`); setCertInfo(null); } catch (err: any) { setError(err.message); } finally { setCertBusy(false); } }; if (loading || !user) { return null; } const columnCount = isAdmin ? 8 : 7; 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 && ( )} {locationDeviceId === d.id && ( )} {certDeviceId === d.id && ( )} ))} {isAdmin && disablingId && devices.some((d) => d.id === disablingId) && ( )}
Name MQTT username Serial Status Last contact Location Certificate}
{d.name} {d.mqttUsername ? {d.mqttUsername} : '—'} {d.serialNumber ?? '—'} {d.isActive ? 'active' : 'disabled'} {!d.isActive && d.disabledReason && (
{d.disabledReason}
)}
{formatRelativeTime(d.lastSeenAt)} {d.isActive ? ( ) : ( )}{' '}
{locationLoading &&

Loading last known position…

} {!locationLoading && locationResult && !locationResult.point && (

No location data yet for {d.name}.

)} {!locationLoading && locationResult?.point && ( <>

Last position {formatRelativeTime(locationResult.point.recordedAt)} {locationResult.job && <> · job {locationResult.job.ticketNumber} — {locationResult.job.title}}

)}
{certLoading &&

Loading certificate…

} {!certLoading && !certInfo && !d.serialNumber && (

Set a serial number on {d.name} before issuing a certificate.

)} {!certLoading && !certInfo && d.serialNumber && ( <>

No certificate issued for {d.name} yet.

)} {!certLoading && certInfo && ( <>

CN {certInfo.commonName} · fingerprint {certInfo.fingerprint}
issued {new Date(certInfo.issuedAt).toLocaleString()} · expires{' '} {new Date(certInfo.expiresAt).toLocaleString()}

)}
{ 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 }} />
); }