diff --git a/package.json b/package.json index 0dac315..f484dd8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "deploy": "npm run build && wrangler deploy", "api:generate": "node scripts/generate-api.mjs", "typecheck": "tsc --noEmit", - "test": "node scripts/run-refresh-test.mjs && node --test src/models/board.test.mjs src/utils/auth.test.mjs src/utils/inlineMarkdown.test.mjs", + "test": "node scripts/run-refresh-test.mjs && node --test src/models/board.test.mjs src/utils/auth.test.mjs src/utils/inlineMarkdown.test.mjs src/utils/boardState.test.mjs", "api:check": "node scripts/generate-api.mjs --check" }, "dependencies": { diff --git a/src/api/client.ts b/src/api/client.ts index ab2fe76..212a748 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -21,6 +21,9 @@ import { const BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080"; +/** The backend's base URL, as a board must be told it at enrollment. */ +export const API_BASE_URL: string = BASE_URL.replace(/\/+$/, ""); + /** Sent on every request; the ngrok tunnel used for demos returns an interstitial without it. */ const STATIC_HEADERS: Record = { "ngrok-skip-browser-warning": "true", diff --git a/src/components/devices/BoardIssueChips.jsx b/src/components/devices/BoardIssueChips.jsx new file mode 100644 index 0000000..46939bb --- /dev/null +++ b/src/components/devices/BoardIssueChips.jsx @@ -0,0 +1,27 @@ +import { boardIssues } from '../../utils/boardState'; + +const STYLES = { + danger: 'bg-red-50 text-red-700 border-red-200', + warning: 'bg-amber-50 text-amber-800 border-amber-200', + info: 'bg-indigo-50 text-indigo-700 border-indigo-200' +}; + +/** What needs attention on a board, as small chips. Nothing when all is well. */ +function BoardIssueChips({ board, reportAt = null, empty = null }) { + const issues = boardIssues(board, { reportAt }); + if (!issues.length) return empty; + return ( +
+ {issues.map((issue) => ( + + {issue.label} + + ))} +
+ ); +} + +export default BoardIssueChips; diff --git a/src/components/devices/BoardStatePanel.jsx b/src/components/devices/BoardStatePanel.jsx new file mode 100644 index 0000000..7e18d3d --- /dev/null +++ b/src/components/devices/BoardStatePanel.jsx @@ -0,0 +1,114 @@ +import { AlertTriangle, CheckCircle2, Clock, Download, Film, History, LayoutTemplate, RefreshCcw } from 'lucide-react'; +import { createElement } from 'react'; +import BoardIssueChips from './BoardIssueChips'; +import { + contentDays, + contentSource, + describeAgentUpdate, + describeClock, + describeContent, + describeUpdates, + updateCheckProblem +} from '../../utils/boardState'; +import { formatRelativeTime } from '../../utils/deviceStatus'; + +const TEXT = { + ok: 'text-gray-900', + warning: 'text-amber-800', + danger: 'text-red-700' +}; + +function Row({ icon, label, children, hint, level = 'ok' }) { + return ( +
+ {createElement(icon, { className: `mt-0.5 h-4 w-4 flex-shrink-0 ${level === 'ok' ? 'text-gray-400' : TEXT[level]}` })} +
+
{label}
+
{children}
+ {hint &&
{hint}
} +
+
+ ); +} + +/** + * What the board runs and shows, from the report it sends with every heartbeat + * (agent 0.3.0 and later): the admin's view of a board nobody is standing in + * front of. + */ +function BoardStatePanel({ device }) { + const board = device.board; + if (!board) { + return ( +

+ This board has not reported what it shows yet. Boards report it from agent 0.3.0 on. +

+ ); + } + + const content = board.content; + const days = contentDays(content); + const contentLevel = board.error || !content || days.staleDays > 0 ? 'danger' : days.daysRemaining <= 2 ? 'warning' : 'ok'; + const updates = describeUpdates(board.updates); + const checkProblem = updateCheckProblem(board.updates); + const agentUpdate = describeAgentUpdate(board.lastAgentUpdate); + const clock = describeClock(board.clock); + + return ( +
+ +
+ {board.appVersion || 'Not installed'} + + {describeContent(content)} + + + {updates || checkProblem || 'Nothing: the board app and agent are up to date'} + + {agentUpdate && ( + + {agentUpdate.text} + + )} + {clock && ( + + {clock.text} + + )} + + {board.syncError ? `Failing: ${board.syncError}` : 'Working'} + +
+

+ + Reported by the board {formatRelativeTime(device.boardReportAt)} +

+
+ ); +} + +export default BoardStatePanel; diff --git a/src/components/devices/CommandOutput.jsx b/src/components/devices/CommandOutput.jsx index c731016..d244938 100644 --- a/src/components/devices/CommandOutput.jsx +++ b/src/components/devices/CommandOutput.jsx @@ -51,10 +51,13 @@ function CommandOutput({ command }) { return (
{output.checkError && ( -
Could not check for updates: {output.checkError}
+
+ Could not check for updates: {output.checkError} + {output.results.length === 0 &&
Nothing was installed. Check the board's internet connection and try again.
} +
)} {output.results.length === 0 ? ( -
Nothing to install: the board app and agent are up to date.
+ !output.checkError &&
Nothing to install: the board app and agent are up to date.
) : (
+ {command} +

The board shows its address on screen while it waits to be enrolled.

- + + + ) : ( + <> +

{device.displayName}

+ + + + + )}

{device.id}

+ {renameError &&

{renameError}

}
@@ -118,6 +179,14 @@ function DeviceDetail() {
+
+
+ +

Board

+
+ +
+

Telemetry

diff --git a/src/pages/DeviceList.jsx b/src/pages/DeviceList.jsx index 10ab428..df5d895 100644 --- a/src/pages/DeviceList.jsx +++ b/src/pages/DeviceList.jsx @@ -3,6 +3,7 @@ import { ChevronLeft, Monitor, Plus, RefreshCcw, Thermometer, WifiOff } from 'lu import { useDeviceList } from '../hooks/useDeviceList'; import Can from '../components/Can'; import DeviceStatusBadge from '../components/devices/DeviceStatusBadge'; +import BoardIssueChips from '../components/devices/BoardIssueChips'; import { audienceLabel, formatRelativeTime } from '../utils/deviceStatus'; import { PERMISSIONS } from '../utils/permissions'; @@ -58,6 +59,7 @@ function DeviceList() { Device Audience Status + Board Last heartbeat CPU Agent @@ -65,9 +67,9 @@ function DeviceList() { {loading ? ( - Loading devices... + Loading devices... ) : devices.length === 0 ? ( - No devices enrolled yet. + No devices enrolled yet. ) : devices.map((device) => ( navigate(`/admin/devices/${device.id}`)} className="cursor-pointer hover:bg-gray-50"> @@ -76,6 +78,13 @@ function DeviceList() { {audienceLabel(device.audience)} + + {device.board ? 'All good' : 'Not reported'}} + /> + {formatRelativeTime(device.lastHeartbeat)} diff --git a/src/services/DeviceService.js b/src/services/DeviceService.js index e47565e..0d199f7 100644 --- a/src/services/DeviceService.js +++ b/src/services/DeviceService.js @@ -32,6 +32,17 @@ class DeviceService { ); } + /** Rename a board. The name is what admins see; the board never shows it. */ + static async renameDevice(deviceId, displayName) { + return this.unwrap( + await api.PATCH('/api/admin/board/devices/{deviceId}', { + params: { path: { deviceId } }, + body: { displayName }, + }), + 'Failed to rename device' + ); + } + static async issueEnrollmentToken(request) { return this.unwrap( await api.POST('/api/admin/board/devices/enrollment-tokens', { body: request }), diff --git a/src/utils/boardState.js b/src/utils/boardState.js new file mode 100644 index 0000000..924978b --- /dev/null +++ b/src/utils/boardState.js @@ -0,0 +1,164 @@ +/** + * The board's report of itself (`device.board`, sent with every heartbeat by + * agent 0.3.0 and later; see the agent's docs/architecture.md, "Board report"), + * turned into what the console shows: plain sentences and the few issues that + * need someone's attention. + * + * { appVersion, + * content: { firstDay, lastDay, createdAt, source, installedAt, daysRemaining, staleDays }, + * updates: { available: [{ type, version, description }], installTime, installAt, installing }, + * lastAgentUpdate: { from, to, at, status: 'ok' | 'rolled-back' | 'refused', message }, + * clock: { source: 'ntp' | 'rtc' | 'uploader' | 'starting' | 'unverified', trusted }, + * syncError, error } + */ + +const SOURCE_LABELS = { + sync: 'synced from LensBridge', + usb: 'from a USB stick', + upload: 'uploaded from a laptop or phone', + cli: 'installed from the command line' +}; + +const CLOCK_LABELS = { + ntp: 'Set from the internet', + rtc: 'Kept by the hardware clock', + uploader: 'Set from a laptop or phone since the board started', + starting: 'Checking (the board just started)', + unverified: 'Not confirmed since the board started: prayer times may be wrong' +}; + +/** Content ending this many days from now or sooner is worth a warning. */ +const CONTENT_LOW_DAYS = 2; + +/** A report this old says so: an offline board's report stops at its last heartbeat. */ +const REPORT_OLD_MS = 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "YYYY-MM-DD" today in `timezone` (the board's), or the viewer's own. */ +function todayIn(timezone, now) { + try { + return new Intl.DateTimeFormat('en-CA', { + year: 'numeric', month: '2-digit', day: '2-digit', timeZone: timezone || undefined, + }).format(now); + } catch { + return now.toISOString().slice(0, 10); + } +} + +/** + * Days of content left, or days since it ran out, worked out from lastDay and + * today rather than taken from the report: a board offline for a week last + * reported "6 days left", and that is no longer true. + */ +export function contentDays(content, now = new Date()) { + if (!content?.lastDay) return { daysRemaining: content?.daysRemaining ?? 0, staleDays: content?.staleDays ?? 0 }; + const diff = Math.round( + (Date.parse(`${content.lastDay}T00:00:00Z`) - Date.parse(`${todayIn(content.timezone, now)}T00:00:00Z`)) / DAY_MS + ); + return diff >= 0 ? { daysRemaining: diff, staleDays: 0 } : { daysRemaining: 0, staleDays: -diff }; +} + +/** "Oct 1" for a board-local "2026-10-01". */ +export function formatDay(day) { + if (!day) return ''; + const date = new Date(`${day}T12:00:00Z`); + if (Number.isNaN(date.getTime())) return day; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); +} + +/** + * "Thu, Sep 25, 11:00 PM" for an RFC 3339 time, read as the board's own wall + * clock (the offset it carries), not the viewer's: "installs at 11:00 PM" means + * the board's 11 PM wherever the admin is. + */ +export function formatBoardTime(rfc3339) { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(rfc3339 || ''); + if (!m) return rfc3339 || ''; + const wall = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5])); + return wall.toLocaleString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' + }); +} + +/** One line about the installed content. */ +export function describeContent(content, now = new Date()) { + if (!content) return 'No content installed'; + const range = `${formatDay(content.firstDay)} to ${formatDay(content.lastDay)}`; + const { daysRemaining: left, staleDays } = contentDays(content, now); + if (staleDays > 0) { + return `${range}: ran out ${staleDays} day${staleDays === 1 ? '' : 's'} ago`; + } + return `${range} (${left} more day${left === 1 ? '' : 's'})`; +} + +export function contentSource(content) { + return SOURCE_LABELS[content?.source] || ''; +} + +/** One line about software waiting for the install window, or null. */ +export function describeUpdates(updates) { + const available = updates?.available ?? []; + if (!available.length) return null; + const what = available.map((u) => u.description).join(' and '); + if (updates.installing) return `Installing ${what} now`; + return updates.installAt ? `${what}, installs ${formatBoardTime(updates.installAt)} (board time)` : what; +} + +/** The last agent self-update, in a sentence, with how it went. */ +export function describeAgentUpdate(u) { + if (!u) return null; + switch (u.status) { + case 'ok': + return { level: 'ok', text: `Updated from ${u.from} to ${u.to}` }; + case 'rolled-back': + return { level: 'danger', text: `Agent ${u.to} failed to start, so the board went back to ${u.from}. It will not be retried.` }; + case 'refused': + return { level: 'warning', text: `Agent ${u.to || 'update'} could not be installed on this board` }; + default: + return { level: 'warning', text: u.message || u.status }; + } +} + +export function describeClock(clock) { + if (!clock) return null; + return { level: clock.trusted === false ? 'danger' : 'ok', text: CLOCK_LABELS[clock.source] || clock.source }; +} + +/** + * What needs someone's attention, most serious first, as short labels for the + * device list and the device page header. Empty when the board is fine or has + * not reported. + */ +/** + * What the portal can say about software updates: waiting ones, a failing + * check, or updates being off. Null when there is nothing to say. + */ +export function updateCheckProblem(updates) { + if (!updates) return null; + if (updates.autoUpdate === false) return 'Automatic updates are off on this board'; + if (updates.lastCheckError) return `Update check failing: ${updates.lastCheckError}`; + return null; +} + +export function boardIssues(board, { reportAt = null, now = new Date() } = {}) { + if (!board) return []; + const issues = []; + if (!board.appVersion) issues.push({ level: 'danger', label: 'No board app' }); + if (board.error) issues.push({ level: 'danger', label: 'Content unreadable' }); + const c = board.content; + if (!c && !board.error) issues.push({ level: 'danger', label: 'No content' }); + const days = contentDays(c, now); + if (c && days.staleDays > 0) issues.push({ level: 'danger', label: `Content ran out ${days.staleDays}d ago` }); + else if (c && days.daysRemaining <= CONTENT_LOW_DAYS) issues.push({ level: 'warning', label: `Content ends in ${days.daysRemaining}d` }); + if (board.clock?.trusted === false) issues.push({ level: 'danger', label: 'Clock unconfirmed' }); + if (board.lastAgentUpdate?.status === 'rolled-back') issues.push({ level: 'danger', label: 'Agent update rolled back' }); + if (board.lastAgentUpdate?.status === 'refused') issues.push({ level: 'warning', label: 'Agent update refused' }); + if (board.syncError) issues.push({ level: 'warning', label: 'Sync failing' }); + if (board.updates?.lastCheckError && board.updates?.autoUpdate !== false) { + issues.push({ level: 'warning', label: 'Update check failing' }); + } + const age = reportAt ? now.getTime() - Date.parse(reportAt) : 0; + if (age > REPORT_OLD_MS) issues.push({ level: 'info', label: `Report ${Math.floor(age / DAY_MS)}d old` }); + if (board.updates?.available?.length) issues.push({ level: 'info', label: 'Update waiting' }); + return issues; +} diff --git a/src/utils/boardState.test.mjs b/src/utils/boardState.test.mjs new file mode 100644 index 0000000..b5d78e2 --- /dev/null +++ b/src/utils/boardState.test.mjs @@ -0,0 +1,91 @@ +/** + * Pins how a board's report reads in the console. The failure mode is quiet: + * a board whose content has run out, or whose clock is unconfirmed, looking + * fine in the device list. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + boardIssues, contentDays, describeContent, describeUpdates, describeAgentUpdate, + formatBoardTime, updateCheckProblem, +} from './boardState.js'; + +// Midday in Toronto, 25 September 2026. +const now = new Date('2026-09-25T16:00:00Z'); +const content = { + firstDay: '2026-09-25', lastDay: '2026-10-01', timezone: 'America/Toronto', + daysRemaining: 6, staleDays: 0, source: 'usb', +}; +const healthy = { appVersion: '2.2.0', content, clock: { source: 'ntp', trusted: true } }; + +test('a healthy board has no issues', () => { + assert.deepEqual(boardIssues(healthy, { now }), []); + assert.deepEqual(boardIssues(null), []); +}); + +test('problems are listed most serious first', () => { + const labels = boardIssues({ + content: { ...content, lastDay: '2026-09-22' }, + clock: { source: 'unverified', trusted: false }, + lastAgentUpdate: { status: 'rolled-back', from: '0.2.1', to: '0.3.0' }, + syncError: 'no route', + updates: { available: [{ description: 'board app 2.3.0' }], autoUpdate: true, lastCheckError: 'dns' }, + }, { now }).map((i) => `${i.level}:${i.label}`); + assert.deepEqual(labels, [ + 'danger:No board app', + 'danger:Content ran out 3d ago', + 'danger:Clock unconfirmed', + 'danger:Agent update rolled back', + 'warning:Sync failing', + 'warning:Update check failing', + 'info:Update waiting', + ]); +}); + +test('days left come from lastDay and today, not the report', () => { + // Reported "6 days left" a week ago; it has since run out. + const later = new Date('2026-10-03T16:00:00Z'); + assert.deepEqual(contentDays(content, later), { daysRemaining: 0, staleDays: 2 }); + assert.deepEqual(contentDays(content, now), { daysRemaining: 6, staleDays: 0 }); + // Just after midnight in Toronto is still the previous day in UTC terms. + assert.deepEqual(contentDays(content, new Date('2026-10-02T03:30:00Z')), { daysRemaining: 0, staleDays: 0 }); +}); + +test('content running low is a warning', () => { + assert.deepEqual( + boardIssues({ ...healthy, content: { ...content, lastDay: '2026-09-26' } }, { now }).map((i) => i.label), + ['Content ends in 1d'] + ); +}); + +test('an old report says so', () => { + const labels = boardIssues(healthy, { reportAt: '2026-09-21T12:00:00Z', now }).map((i) => i.label); + assert.deepEqual(labels, ['Report 4d old']); +}); + +test('content reads as a range with days left', () => { + assert.equal(describeContent(content, now), 'Sep 25 to Oct 1 (6 more days)'); + assert.equal(describeContent(content, new Date('2026-10-02T16:00:00Z')), 'Sep 25 to Oct 1: ran out 1 day ago'); + assert.equal(describeContent(null), 'No content installed'); +}); + +test('update checks: off, failing, or fine', () => { + assert.equal(updateCheckProblem({ autoUpdate: false }), 'Automatic updates are off on this board'); + assert.equal(updateCheckProblem({ autoUpdate: true, lastCheckError: 'dns' }), 'Update check failing: dns'); + assert.equal(updateCheckProblem({ autoUpdate: true, lastCheckAt: '2026-09-25T12:00:00Z' }), null); +}); + +test('install times are the board\'s wall clock, not the viewer\'s', () => { + assert.equal(formatBoardTime('2026-09-25T23:00:00-04:00'), 'Fri, Sep 25, 11:00 PM'); + assert.match( + describeUpdates({ available: [{ description: 'board app 2.3.0' }], installAt: '2026-09-25T23:00:00-04:00' }), + /^board app 2\.3\.0, installs Fri, Sep 25, 11:00 PM \(board time\)$/ + ); + assert.equal(describeUpdates({ available: [] }), null); +}); + +test('a rolled back agent update says so plainly', () => { + const r = describeAgentUpdate({ status: 'rolled-back', from: '0.2.1', to: '0.3.0' }); + assert.equal(r.level, 'danger'); + assert.match(r.text, /0\.3\.0 failed to start, so the board went back to 0\.2\.1/); +});