From b9f3da1eb5971fe44b887692867e5d179d7f1c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:39:52 +0000 Subject: [PATCH 1/4] Show what each board runs and shows, from its own report Boards (agent 0.3.0 on) report their state with every heartbeat and the backend returns it as `board` on each device. The device page gets a Board card: board app version, content range and days left (and how it arrived), software waiting for the install window with its board-time install time, the last agent update (a rollback in red), whether the clock is confirmed, and content sync. The device list gets a Board column of issue chips: content ran out or ending, clock unconfirmed, agent update rolled back or refused, sync failing, update waiting. Heartbeat events update both live. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- package.json | 2 +- src/components/devices/BoardIssueChips.jsx | 27 +++++ src/components/devices/BoardStatePanel.jsx | 109 +++++++++++++++++++ src/hooks/useDevice.js | 4 +- src/hooks/useDeviceList.js | 3 +- src/pages/DeviceDetail.jsx | 11 +- src/pages/DeviceList.jsx | 12 ++- src/utils/boardState.js | 118 +++++++++++++++++++++ src/utils/boardState.test.mjs | 57 ++++++++++ 9 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 src/components/devices/BoardIssueChips.jsx create mode 100644 src/components/devices/BoardStatePanel.jsx create mode 100644 src/utils/boardState.js create mode 100644 src/utils/boardState.test.mjs diff --git a/package.json b/package.json index e22c4cf..6bcfb94 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "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/components/devices/BoardIssueChips.jsx b/src/components/devices/BoardIssueChips.jsx new file mode 100644 index 0000000..eb5a210 --- /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, empty = null }) { + const issues = boardIssues(board); + 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..bfd1a36 --- /dev/null +++ b/src/components/devices/BoardStatePanel.jsx @@ -0,0 +1,109 @@ +import { AlertTriangle, CheckCircle2, Clock, Download, Film, History, LayoutTemplate, RefreshCcw } from 'lucide-react'; +import { createElement } from 'react'; +import BoardIssueChips from './BoardIssueChips'; +import { + contentSource, + describeAgentUpdate, + describeClock, + describeContent, + describeUpdates +} 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 contentLevel = board.error || !content || content.staleDays > 0 ? 'danger' : content.daysRemaining <= 2 ? 'warning' : 'ok'; + const updates = describeUpdates(board.updates); + const agentUpdate = describeAgentUpdate(board.lastAgentUpdate); + const clock = describeClock(board.clock); + + return ( +
+ +
+ {board.appVersion || 'Not installed'} + + {describeContent(content)} + + + {updates || '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/hooks/useDevice.js b/src/hooks/useDevice.js index 5d3bfdc..609b596 100644 --- a/src/hooks/useDevice.js +++ b/src/hooks/useDevice.js @@ -50,7 +50,9 @@ export function useDevice(deviceId) { setDevice((prev) => prev ? { ...prev, lastHeartbeat: event.at, - lastSeenIp: event.telemetry?.ipv4?.[0] || prev.lastSeenIp + lastSeenIp: event.telemetry?.ipv4?.[0] || prev.lastSeenIp, + // The board's report of itself rides on the heartbeat too. + ...(event.telemetry?.board ? { board: event.telemetry.board, boardReportAt: event.at } : {}) } : prev); setTelemetrySamples((prev) => [ ...prev, diff --git a/src/hooks/useDeviceList.js b/src/hooks/useDeviceList.js index 46188d2..6a3e21b 100644 --- a/src/hooks/useDeviceList.js +++ b/src/hooks/useDeviceList.js @@ -67,7 +67,8 @@ export function useDeviceList({ enabled = true } = {}) { ? { ...device, lastHeartbeat: event.at, - lastSeenIp: event.telemetry?.ipv4?.[0] || device.lastSeenIp + lastSeenIp: event.telemetry?.ipv4?.[0] || device.lastSeenIp, + ...(event.telemetry?.board ? { board: event.telemetry.board, boardReportAt: event.at } : {}) } : device ))); diff --git a/src/pages/DeviceDetail.jsx b/src/pages/DeviceDetail.jsx index 4366ce9..d0021a9 100644 --- a/src/pages/DeviceDetail.jsx +++ b/src/pages/DeviceDetail.jsx @@ -1,12 +1,13 @@ import { Link, useParams } from 'react-router-dom'; import { useState } from 'react'; -import { Ban, ChevronLeft, Loader2, Monitor, RefreshCcw, Server } from 'lucide-react'; +import { Ban, ChevronLeft, LayoutDashboard, Loader2, Monitor, RefreshCcw, Server } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { useDevice } from '../hooks/useDevice'; import { upsertCommand, useCommandStream } from '../hooks/useCommandStream'; import DeviceService from '../services/DeviceService'; import DeviceStatusBadge from '../components/devices/DeviceStatusBadge'; import TelemetryPanel from '../components/devices/TelemetryPanel'; +import BoardStatePanel from '../components/devices/BoardStatePanel'; import CommandLauncher from '../components/devices/CommandLauncher'; import CommandRow from '../components/devices/CommandRow'; import DeviceBoardConfig from '../components/devices/DeviceBoardConfig'; @@ -117,6 +118,14 @@ function DeviceDetail() { +
+
+ +

Board

+
+ +
+

Telemetry

diff --git a/src/pages/DeviceList.jsx b/src/pages/DeviceList.jsx index 10ab428..5ee8a25 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,12 @@ function DeviceList() { {audienceLabel(device.audience)} + + {device.board ? 'All good' : 'Not reported'}} + /> + {formatRelativeTime(device.lastHeartbeat)} diff --git a/src/utils/boardState.js b/src/utils/boardState.js new file mode 100644 index 0000000..b1ed63d --- /dev/null +++ b/src/utils/boardState.js @@ -0,0 +1,118 @@ +/** + * 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; + +/** "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) { + if (!content) return 'No content installed'; + const range = `${formatDay(content.firstDay)} to ${formatDay(content.lastDay)}`; + if (content.staleDays > 0) { + return `${range}: ran out ${content.staleDays} day${content.staleDays === 1 ? '' : 's'} ago`; + } + const left = content.daysRemaining; + 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. + */ +export function boardIssues(board) { + if (!board) return []; + const issues = []; + 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' }); + if (c?.staleDays > 0) issues.push({ level: 'danger', label: `Content ran out ${c.staleDays}d ago` }); + else if (c && c.daysRemaining <= CONTENT_LOW_DAYS) issues.push({ level: 'warning', label: `Content ends in ${c.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?.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..938623a --- /dev/null +++ b/src/utils/boardState.test.mjs @@ -0,0 +1,57 @@ +/** + * 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, describeContent, describeUpdates, describeAgentUpdate, formatBoardTime } from './boardState.js'; + +const content = { firstDay: '2026-09-25', lastDay: '2026-10-01', daysRemaining: 6, staleDays: 0, source: 'usb' }; + +test('a healthy board has no issues', () => { + assert.deepEqual(boardIssues({ appVersion: '2.2.0', content, clock: { source: 'ntp', trusted: true } }), []); + assert.deepEqual(boardIssues(null), []); +}); + +test('problems are listed most serious first', () => { + const labels = boardIssues({ + content: { ...content, daysRemaining: 0, staleDays: 3 }, + 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' }] } + }).map((i) => `${i.level}:${i.label}`); + assert.deepEqual(labels, [ + 'danger:Content ran out 3d ago', + 'danger:Clock unconfirmed', + 'danger:Agent update rolled back', + 'warning:Sync failing', + 'info:Update waiting' + ]); +}); + +test('content running low is a warning', () => { + assert.deepEqual(boardIssues({ content: { ...content, daysRemaining: 1 } }).map((i) => i.label), ['Content ends in 1d']); +}); + +test('content reads as a range with days left', () => { + assert.equal(describeContent(content), 'Sep 25 to Oct 1 (6 more days)'); + assert.equal(describeContent({ ...content, staleDays: 1 }), 'Sep 25 to Oct 1: ran out 1 day ago'); + assert.equal(describeContent(null), 'No content installed'); +}); + +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/); +}); From ff96804b019347d84927ace966632cc30237fdd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:49:46 +0000 Subject: [PATCH 2/4] Enroll dialog: the real backend URL, sudo, and a copy button The command it showed pointed the board at http://localhost:8080, which on a Pi is the agent's own local server, and left out sudo, so enrollment failed with a confusing error. It is now built from this console's API base URL (API_BASE_URL from api/client), starts with sudo, and has its own "Copy command" button beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/api/client.ts | 3 ++ src/components/devices/EnrollTokenDialog.jsx | 32 +++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) 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/EnrollTokenDialog.jsx b/src/components/devices/EnrollTokenDialog.jsx index c1195be..cb40699 100644 --- a/src/components/devices/EnrollTokenDialog.jsx +++ b/src/components/devices/EnrollTokenDialog.jsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Copy, Printer, QrCode, X } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; +import { API_BASE_URL } from '../../api/client'; function EnrollTokenDialog({ tokenResponse, onClose }) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(''); const [showQr, setShowQr] = useState(true); const [now, setNow] = useState(Date.now()); @@ -20,10 +21,14 @@ function EnrollTokenDialog({ tokenResponse, onClose }) { return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; }, [tokenResponse.expiresAt, now]); - const copyToken = async () => { - await navigator.clipboard.writeText(tokenResponse.token); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + // What the installer runs on the board, over SSH. The backend URL is this + // console's own API: a board enrolled against anything else cannot sync. + const command = `sudo musallahboard-agent enroll --token=${tokenResponse.token} --backend=${API_BASE_URL}`; + + const copy = async (what, text) => { + await navigator.clipboard.writeText(text); + setCopied(what); + setTimeout(() => setCopied(''), 2000); }; return ( @@ -67,17 +72,22 @@ function EnrollTokenDialog({ tokenResponse, onClose }) { )}
-
Pi enroll command
- - musallahboard-agent enroll --token={tokenResponse.token} --backend=http://localhost:8080 - +
+
Run this on the board (over SSH)
+ +
+ {command} +

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

- + + + ) : ( + <> +

{device.displayName}

+ + + + + )}

{device.id}

+ {renameError &&

{renameError}

}
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 }), From d28e2fa570c17e200e73915afefe93e7bb46c888 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:09:31 +0000 Subject: [PATCH 4/4] Board card and chips that stay true as reports age - Days of content left are worked out from lastDay and today, in the board's time zone, not taken from the report: an offline board's last report said "6 days left" a week ago. - A report older than a day gets a "Report Nd old" chip. - "Update check failing" and "Automatic updates are off" instead of claiming the board is up to date, and a "No board app" chip. - After saving config or the ticker: "Online boards update within a minute; offline boards need a new offline bundle." - Update Now no longer says "up to date" after a failed check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/components/devices/BoardIssueChips.jsx | 4 +- src/components/devices/BoardStatePanel.jsx | 13 +++-- src/components/devices/CommandOutput.jsx | 7 ++- src/components/devices/DeviceBoardConfig.jsx | 15 +++++ src/pages/DeviceList.jsx | 1 + src/utils/boardState.js | 60 +++++++++++++++++--- src/utils/boardState.test.mjs | 54 ++++++++++++++---- 7 files changed, 129 insertions(+), 25 deletions(-) diff --git a/src/components/devices/BoardIssueChips.jsx b/src/components/devices/BoardIssueChips.jsx index eb5a210..46939bb 100644 --- a/src/components/devices/BoardIssueChips.jsx +++ b/src/components/devices/BoardIssueChips.jsx @@ -7,8 +7,8 @@ const STYLES = { }; /** What needs attention on a board, as small chips. Nothing when all is well. */ -function BoardIssueChips({ board, empty = null }) { - const issues = boardIssues(board); +function BoardIssueChips({ board, reportAt = null, empty = null }) { + const issues = boardIssues(board, { reportAt }); if (!issues.length) return empty; return (
diff --git a/src/components/devices/BoardStatePanel.jsx b/src/components/devices/BoardStatePanel.jsx index bfd1a36..7e18d3d 100644 --- a/src/components/devices/BoardStatePanel.jsx +++ b/src/components/devices/BoardStatePanel.jsx @@ -2,11 +2,13 @@ import { AlertTriangle, CheckCircle2, Clock, Download, Film, History, LayoutTemp import { createElement } from 'react'; import BoardIssueChips from './BoardIssueChips'; import { + contentDays, contentSource, describeAgentUpdate, describeClock, describeContent, - describeUpdates + describeUpdates, + updateCheckProblem } from '../../utils/boardState'; import { formatRelativeTime } from '../../utils/deviceStatus'; @@ -45,14 +47,16 @@ function BoardStatePanel({ device }) { } const content = board.content; - const contentLevel = board.error || !content || content.staleDays > 0 ? 'danger' : content.daysRemaining <= 2 ? 'warning' : 'ok'; + 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'} - {updates || 'Nothing: the board app and agent are up to date'} + {updates || checkProblem || 'Nothing: the board app and agent are up to date'} {agentUpdate && ( {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.
) : (
    {output.results.map((result, index) => ( diff --git a/src/components/devices/DeviceBoardConfig.jsx b/src/components/devices/DeviceBoardConfig.jsx index a422812..e658778 100644 --- a/src/components/devices/DeviceBoardConfig.jsx +++ b/src/components/devices/DeviceBoardConfig.jsx @@ -15,6 +15,19 @@ import { PERMISSIONS } from '../../utils/permissions'; * button that always 403s for the role most likely to press it. */ +/** + * Where a save goes: online boards pick it up on their own, offline ones only + * from a new bundle. Said right after saving, when it matters. + */ +function SavedHint({ show }) { + if (!show) return null; + return ( + + Online boards update within a minute; offline boards need a new offline bundle. + + ); +} + function SaveButton({ dirty, saving, saved, disabled, onSave }) { return (