Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"ngrok-skip-browser-warning": "true",
Expand Down
27 changes: 27 additions & 0 deletions src/components/devices/BoardIssueChips.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap gap-1.5">
{issues.map((issue) => (
<span
key={issue.label}
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${STYLES[issue.level]}`}
>
{issue.label}
</span>
))}
</div>
);
}

export default BoardIssueChips;
114 changes: 114 additions & 0 deletions src/components/devices/BoardStatePanel.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-3">
{createElement(icon, { className: `mt-0.5 h-4 w-4 flex-shrink-0 ${level === 'ok' ? 'text-gray-400' : TEXT[level]}` })}
<div className="min-w-0">
<div className="text-sm text-gray-500">{label}</div>
<div className={`mt-0.5 font-medium ${TEXT[level]}`}>{children}</div>
{hint && <div className="mt-0.5 text-sm text-gray-500">{hint}</div>}
</div>
</div>
);
}

/**
* 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 (
<p className="text-sm text-gray-500">
This board has not reported what it shows yet. Boards report it from agent 0.3.0 on.
</p>
);
}

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 (
<div className="space-y-5">
<BoardIssueChips board={board} reportAt={device.boardReportAt} />
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<Row icon={LayoutTemplate} label="Board app">{board.appVersion || 'Not installed'}</Row>
<Row
icon={Film}
label="Content"
level={contentLevel}
hint={board.error || [contentSource(content), content?.installedAt && `installed ${formatRelativeTime(content.installedAt)}`].filter(Boolean).join(', ')}
>
{describeContent(content)}
</Row>
<Row
icon={Download}
label="Waiting to install"
level={checkProblem && !updates ? 'warning' : 'ok'}
hint={updates && !board.updates?.installing ? 'Update Now, under Commands, installs it straight away.' : null}
>
{updates || checkProblem || 'Nothing: the board app and agent are up to date'}
</Row>
{agentUpdate && (
<Row
icon={History}
label="Last agent update"
level={agentUpdate.level === 'ok' ? 'ok' : agentUpdate.level}
hint={board.lastAgentUpdate?.at ? formatRelativeTime(board.lastAgentUpdate.at) : null}
>
{agentUpdate.text}
</Row>
)}
{clock && (
<Row
icon={Clock}
label="Clock"
level={clock.level}
hint={clock.level === 'danger' ? 'Connect the board to the internet, fit an RTC, or send it an update from a laptop.' : null}
>
{clock.text}
</Row>
)}
<Row
icon={board.syncError ? AlertTriangle : RefreshCcw}
label="Content sync"
level={board.syncError ? 'warning' : 'ok'}
>
{board.syncError ? `Failing: ${board.syncError}` : 'Working'}
</Row>
</div>
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<CheckCircle2 className="h-3.5 w-3.5" />
Reported by the board {formatRelativeTime(device.boardReportAt)}
</p>
</div>
);
}

export default BoardStatePanel;
7 changes: 5 additions & 2 deletions src/components/devices/CommandOutput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ function CommandOutput({ command }) {
return (
<div className="space-y-2 text-sm">
{output.checkError && (
<div className="rounded-lg bg-amber-50 p-3 text-amber-800">Could not check for updates: {output.checkError}</div>
<div className="rounded-lg bg-amber-50 p-3 text-amber-800">
Could not check for updates: {output.checkError}
{output.results.length === 0 && <div className="mt-1">Nothing was installed. Check the board&apos;s internet connection and try again.</div>}
</div>
)}
{output.results.length === 0 ? (
<div className="text-gray-600">Nothing to install: the board app and agent are up to date.</div>
!output.checkError && <div className="text-gray-600">Nothing to install: the board app and agent are up to date.</div>
) : (
<ul className="space-y-1">
{output.results.map((result, index) => (
Expand Down
15 changes: 15 additions & 0 deletions src/components/devices/DeviceBoardConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="hidden text-xs text-gray-500 md:inline">
Online boards update within a minute; offline boards need a new offline bundle.
</span>
);
}

function SaveButton({ dirty, saving, saved, disabled, onSave }) {
return (
<button
Expand Down Expand Up @@ -174,6 +187,7 @@ function DeviceBoardConfig({ deviceId, disabled }) {
</div>
{canWriteConfig && (
<div className="flex items-center gap-2">
<SavedHint show={configSaved} />
{configDirty && <DiscardButton saving={configSaving} onDiscard={discardConfig} />}
<SaveButton
dirty={configDirty}
Expand Down Expand Up @@ -214,6 +228,7 @@ function DeviceBoardConfig({ deviceId, disabled }) {
</div>
{canWriteTicker && (
<div className="flex items-center gap-2">
<SavedHint show={tickerSaved} />
{tickerDirty && <DiscardButton saving={tickerSaving} onDiscard={discardTicker} />}
<SaveButton
dirty={tickerDirty}
Expand Down
32 changes: 21 additions & 11 deletions src/components/devices/EnrollTokenDialog.jsx
Original file line number Diff line number Diff line change
@@ -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());

Expand All @@ -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 (
Expand Down Expand Up @@ -67,17 +72,22 @@ function EnrollTokenDialog({ tokenResponse, onClose }) {
)}

<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-medium text-gray-700">Pi enroll command</div>
<code className="block break-all rounded bg-white p-3 text-sm text-gray-800">
musallahboard-agent enroll --token={tokenResponse.token} --backend=http://localhost:8080
</code>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-gray-700">Run this on the board (over SSH)</div>
<button type="button" onClick={() => copy('command', command)} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
<Copy className="h-4 w-4" />
{copied === 'command' ? 'Copied' : 'Copy command'}
</button>
</div>
<code className="block break-all rounded bg-white p-3 text-sm text-gray-800">{command}</code>
<p className="mt-2 text-xs text-gray-500">The board shows its address on screen while it waits to be enrolled.</p>
</div>
</div>

<div className="flex flex-wrap justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button type="button" onClick={copyToken} className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
<button type="button" onClick={() => copy('token', tokenResponse.token)} className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
<Copy className="h-4 w-4" />
{copied ? 'Copied' : 'Copy token'}
{copied === 'token' ? 'Copied' : 'Copy token'}
</button>
<button type="button" onClick={() => setShowQr((value) => !value)} className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
<QrCode className="h-4 w-4" />
Expand Down
8 changes: 7 additions & 1 deletion src/components/devices/OfflineBundleDownload.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ function OfflineBundleDownload({ deviceId }) {
<div className="mb-5">
<h2 className="text-lg font-semibold text-gray-900">Offline bundle</h2>
<p className="mt-1 text-sm text-gray-500">
Plug a laptop into the board&apos;s ethernet port and run <code className="font-mono">mbpush &lt;file&gt;</code> with the downloaded zip.
For a board without internet: a signed <code className="font-mono">.mbu</code> file with this board&apos;s
content for the coming days. Bundles for several boards can share one stick; each board takes only its own.
</p>
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-sm text-gray-500">
<li>Copy it to a USB stick (FAT32 or exFAT, at the top or in a <code className="font-mono">MusallahBoard</code> folder) and plug it into the board.</li>
<li>Or connect a laptop or phone to the board&apos;s ethernet port and open <code className="font-mono">http://10.77.0.1/</code> (boards set up with the service port).</li>
</ul>
<p className="mt-2 text-sm text-gray-500">Content you edit after downloading reaches an offline board only with a new bundle.</p>
</div>
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
<div className="flex flex-wrap items-end gap-3">
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useDevice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useDeviceList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
)));
Expand Down
Loading
Loading