feat(admin): unclaimed-boards view — see powered-on boards awaiting claim

Surfaces the self-register pool in the instructor console so you can tell
at a glance which boards are up but not yet claimed.

- api: GET /nodes/unclaimed (admin-only; kit ids, never secrets) +
  broadcast unclaimed:update on self-register (added) and claim (removed).
  The WS connect snapshot now carries the current unclaimed kit ids
  (attachWs takes the pool), so a freshly opened console is populated
  without waiting for a change.
- web: useCollective tracks `unclaimed` (snapshot + unclaimed:update, with
  a REST getUnclaimed seed for the poll fallback); Admin shows an
  "Unclaimed" stat + a live "Unclaimed boards" panel of kit chips.

Verified live: empty → self-register adds the kit → claim removes it;
judge code is 401 on the endpoint.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-07 16:49:27 -07:00
co-authored by Claude Opus 4.8
parent 8da6d1d7c1
commit 71a255cf15
12 changed files with 135 additions and 12 deletions
+13
View File
@@ -117,6 +117,17 @@ export function createApp(opts: AppOptions): Express {
res.json(nodes.list()) res.json(nodes.list())
}) })
// Instructor view: kit ids of boards that have powered on + self-registered
// but no team has claimed yet. Never exposes url/token/claimCode.
app.get('/nodes/unclaimed', requireCode(adminCode), (_req, res) => {
if (!unclaimed) return res.status(503).json({ error: 'claim unavailable' })
res.json({ kits: unclaimed.list().map((u) => u.kitId) })
})
const broadcastUnclaimed = () => {
if (unclaimed) broadcast({ type: 'unclaimed:update', kits: unclaimed.list().map((u) => u.kitId) })
}
app.post('/nodes', requireCode(adminCode), async (req, res) => { app.post('/nodes', requireCode(adminCode), async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' }) if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
@@ -140,6 +151,7 @@ export function createApp(opts: AppOptions): Express {
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' }) return res.status(400).json({ error: 'kitId, url, token and claimCode are required' })
} }
unclaimed.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode }) unclaimed.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode })
broadcastUnclaimed()
res.status(201).json({ kitId: b.kitId }) res.status(201).json({ kitId: b.kitId })
}) })
@@ -178,6 +190,7 @@ export function createApp(opts: AppOptions): Express {
} }
store.upsertTeam(team) store.upsertTeam(team)
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
broadcastUnclaimed() // the claimed kit left the pool
const online = nodes.list().find((n) => n.teamId === b.teamId)?.online ?? false const online = nodes.list().find((n) => n.teamId === b.teamId)?.online ?? false
res.status(201).json({ teamId: b.teamId, kit: b.kit, online }) res.status(201).json({ teamId: b.teamId, kit: b.kit, online })
}) })
+13
View File
@@ -59,6 +59,15 @@ describe('board self-register + claim', () => {
const res = await selfRegister().expect(201) const res = await selfRegister().expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07' }) expect(res.body).toEqual({ kitId: 'KIT-07' })
}) })
it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
await selfRegister().expect(201)
expect(events).toContainEqual({ type: 'unclaimed:update', kits: ['KIT-07'] })
// instructor-only view
await request(app).get('/nodes/unclaimed').expect(401)
const res = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(res.body).toEqual({ kits: ['KIT-07'] })
})
}) })
describe('claim', () => { describe('claim', () => {
@@ -95,6 +104,10 @@ describe('board self-register + claim', () => {
// participants + judges get the live updates // participants + judges get the live updates
expect(events).toContainEqual({ type: 'node:status', teamId: 'team-07', online: true }) expect(events).toContainEqual({ type: 'node:status', teamId: 'team-07', online: true })
expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true) expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true)
// the claimed kit leaves the unclaimed pool (broadcast + endpoint)
expect(events).toContainEqual({ type: 'unclaimed:update', kits: [] })
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(pool.body).toEqual({ kits: [] })
}) })
it('exposes public per-team liveness after a claim', async () => { it('exposes public per-team liveness after a claim', async () => {
+1 -1
View File
@@ -36,7 +36,7 @@ const app = createApp({
}) })
const server = http.createServer(app) const server = http.createServer(app)
attachWs(server, { store, hub, adminCode: ADMIN_CODE, judgeCode: JUDGE_CODE }) attachWs(server, { store, hub, adminCode: ADMIN_CODE, judgeCode: JUDGE_CODE, unclaimed })
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`[apess-api] listening on :${PORT} (db: ${DB_PATH})`) console.log(`[apess-api] listening on :${PORT} (db: ${DB_PATH})`)
+3 -1
View File
@@ -67,9 +67,11 @@ export interface LeaderboardRow {
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] } | { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] }
| { type: 'team:update'; team: TeamSnapshot } | { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary } | { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number } | { type: 'score:new'; teamId: string; total: number }
| { type: 'node:status'; teamId: string; online: boolean } | { type: 'node:status'; teamId: string; online: boolean }
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string } | { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
// Kit ids of boards that have self-registered but aren't claimed yet.
| { type: 'unclaimed:update'; kits: string[] }
+9 -2
View File
@@ -7,6 +7,7 @@ import { openStore, type Store } from './db'
import { createHub } from './hub' import { createHub } from './hub'
import { createApp } from './app' import { createApp } from './app'
import { attachWs } from './ws' import { attachWs } from './ws'
import { createUnclaimedPool } from './claim'
import type { WsEvent } from './types' import type { WsEvent } from './types'
const ADMIN = 'admin-code' const ADMIN = 'admin-code'
@@ -24,9 +25,12 @@ describe('collective WS feed', () => {
beforeEach(async () => { beforeEach(async () => {
store = openStore(':memory:') store = openStore(':memory:')
const hub = createHub() const hub = createHub()
const unclaimed = createUnclaimedPool([
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111' },
])
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE }) const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
server = http.createServer(app) server = http.createServer(app)
attachWs(server, { store, hub, adminCode: ADMIN, judgeCode: JUDGE }) attachWs(server, { store, hub, adminCode: ADMIN, judgeCode: JUDGE, unclaimed })
await new Promise<void>((r) => server.listen(0, r)) await new Promise<void>((r) => server.listen(0, r))
port = (server.address() as AddressInfo).port port = (server.address() as AddressInfo).port
}) })
@@ -56,7 +60,10 @@ describe('collective WS feed', () => {
const ws = new WebSocket(`ws://localhost:${port}/ws?code=${ADMIN}`) const ws = new WebSocket(`ws://localhost:${port}/ws?code=${ADMIN}`)
const msg = await nextMessage(ws) const msg = await nextMessage(ws)
expect(msg.type).toBe('snapshot') expect(msg.type).toBe('snapshot')
if (msg.type === 'snapshot') expect(msg.teams).toHaveLength(1) if (msg.type === 'snapshot') {
expect(msg.teams).toHaveLength(1)
expect(msg.unclaimed).toEqual(['KIT-05']) // powered-on, unclaimed boards ride along
}
ws.close() ws.close()
}) })
+8 -1
View File
@@ -2,6 +2,7 @@ import { WebSocketServer } from 'ws'
import type { Server } from 'node:http' import type { Server } from 'node:http'
import type { Store } from './db' import type { Store } from './db'
import type { Hub } from './hub' import type { Hub } from './hub'
import type { UnclaimedPool } from './claim'
import { matches } from './auth' import { matches } from './auth'
const HEARTBEAT_MS = 30000 const HEARTBEAT_MS = 30000
@@ -11,10 +12,15 @@ export interface WsOptions {
hub: Hub hub: Hub
adminCode: string adminCode: string
judgeCode: string judgeCode: string
/** Optional unclaimed-board pool; its kit ids ride along in the snapshot. */
unclaimed?: UnclaimedPool
} }
/** Attach the collective WS feed at /ws: auth via ?code=, snapshot on connect. */ /** Attach the collective WS feed at /ws: auth via ?code=, snapshot on connect. */
export function attachWs(server: Server, { store, hub, adminCode, judgeCode }: WsOptions): WebSocketServer { export function attachWs(
server: Server,
{ store, hub, adminCode, judgeCode, unclaimed }: WsOptions,
): WebSocketServer {
const wss = new WebSocketServer({ server, path: '/ws' }) const wss = new WebSocketServer({ server, path: '/ws' })
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
@@ -29,6 +35,7 @@ export function attachWs(server: Server, { store, hub, adminCode, judgeCode }: W
type: 'snapshot', type: 'snapshot',
teams: store.listTeams(), teams: store.listTeams(),
submissions: store.listSubmissions(), submissions: store.listSubmissions(),
unclaimed: unclaimed?.list().map((u) => u.kitId) ?? [],
}), }),
) )
hub.add(ws) hub.add(ws)
+7
View File
@@ -116,6 +116,13 @@ export async function getNodeStatus(teamId: string): Promise<{ teamId: string; o
return (await res.json()) as { teamId: string; online: boolean } return (await res.json()) as { teamId: string; online: boolean }
} }
/** Instructor-only: kit ids of boards that have self-registered but are unclaimed. */
export async function getUnclaimed(code: string): Promise<string[]> {
const res = await fetch(`${API_BASE}/nodes/unclaimed`, { headers: authHeaders(code) })
if (!res.ok) throw new Error(`getUnclaimed ${res.status}`)
return ((await res.json()) as { kits: string[] }).kits
}
// --- ZeroClaw node (participant) ------------------------------------------ // --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */ /** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> { export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
+19 -1
View File
@@ -13,7 +13,7 @@ const team = (id: string): TeamSnapshot => ({
updatedAt: '2026-07-27T13:00:00.000Z', updatedAt: '2026-07-27T13:00:00.000Z',
}) })
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {} } const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] }
describe('collectiveReducer', () => { describe('collectiveReducer', () => {
it('seeds from a snapshot', () => { it('seeds from a snapshot', () => {
@@ -26,6 +26,24 @@ describe('collectiveReducer', () => {
expect(next.submissions.a.scored).toBe(false) expect(next.submissions.a.scored).toBe(false)
}) })
it('seeds unclaimed kits from a snapshot and replaces them on unclaimed:update', () => {
const seeded = collectiveReducer(empty, {
type: 'snapshot',
teams: [],
submissions: [],
unclaimed: ['KIT-03', 'KIT-07'],
})
expect(seeded.unclaimed).toEqual(['KIT-03', 'KIT-07'])
const next = collectiveReducer(seeded, { type: 'unclaimed:update', kits: ['KIT-07'] })
expect(next.unclaimed).toEqual(['KIT-07'])
})
it('preserves unclaimed when a snapshot omits it (REST seed)', () => {
const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] })
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
expect(next.unclaimed).toEqual(['KIT-01'])
})
it('patches a single team on team:update', () => { it('patches a single team on team:update', () => {
const seeded = collectiveReducer(empty, { type: 'snapshot', teams: [team('a')], submissions: [] }) const seeded = collectiveReducer(empty, { type: 'snapshot', teams: [team('a')], submissions: [] })
const next = collectiveReducer(seeded, { type: 'team:update', team: { ...team('a'), name: 'renamed' } }) const next = collectiveReducer(seeded, { type: 'team:update', team: { ...team('a'), name: 'renamed' } })
+15 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from 'react' import { useEffect, useReducer, useRef, useState } from 'react'
import { openCollective, getTeams, getSubmissions } from './api' import { openCollective, getTeams, getSubmissions, getUnclaimed } from './api'
import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types' import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types'
export interface NodeActivityEntry { export interface NodeActivityEntry {
@@ -25,10 +25,12 @@ export interface CollectiveState {
activity: NodeActivityEntry[] activity: NodeActivityEntry[]
/** teamId → tallies (calls/flashes/errors). */ /** teamId → tallies (calls/flashes/errors). */
counts: Record<string, NodeCounts> counts: Record<string, NodeCounts>
/** kit ids of powered-on boards no team has claimed yet. */
unclaimed: string[]
} }
const MAX_ACTIVITY = 40 const MAX_ACTIVITY = 40
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {} } const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] }
const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 } const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 }
const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = { const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = {
@@ -44,8 +46,10 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
event.teams.forEach((t) => (teams[t.id] = t)) event.teams.forEach((t) => (teams[t.id] = t))
const submissions: Record<string, SubmissionSummary> = {} const submissions: Record<string, SubmissionSummary> = {}
event.submissions.forEach((s) => (submissions[s.teamId] = s)) event.submissions.forEach((s) => (submissions[s.teamId] = s))
return { ...state, teams, submissions } return { ...state, teams, submissions, unclaimed: event.unclaimed ?? state.unclaimed }
} }
case 'unclaimed:update':
return { ...state, unclaimed: event.kits }
case 'team:update': case 'team:update':
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } } return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
case 'submission:new': case 'submission:new':
@@ -91,6 +95,7 @@ export interface Collective {
nodes: Record<string, boolean> nodes: Record<string, boolean>
activity: NodeActivityEntry[] activity: NodeActivityEntry[]
counts: Record<string, NodeCounts> counts: Record<string, NodeCounts>
unclaimed: string[]
status: CollectiveStatus status: CollectiveStatus
} }
@@ -116,6 +121,12 @@ export function useCollective(code: string): Collective {
} catch { } catch {
/* the WS snapshot may still arrive; leave state as-is */ /* the WS snapshot may still arrive; leave state as-is */
} }
try {
const kits = await getUnclaimed(code)
if (!cancelled && !live.current) dispatch({ type: 'unclaimed:update', kits })
} catch {
/* judge code (401) or the WS snapshot will carry it */
}
} }
void poll() void poll()
@@ -147,6 +158,7 @@ export function useCollective(code: string): Collective {
nodes: state.nodes, nodes: state.nodes,
activity: state.activity, activity: state.activity,
counts: state.counts, counts: state.counts,
unclaimed: state.unclaimed,
status, status,
} }
} }
+14
View File
@@ -12,6 +12,7 @@ vi.mock('@/lib/api', () => ({
}, },
getTeams: vi.fn().mockResolvedValue([]), getTeams: vi.fn().mockResolvedValue([]),
getSubmissions: vi.fn().mockResolvedValue([]), getSubmissions: vi.fn().mockResolvedValue([]),
getUnclaimed: vi.fn().mockResolvedValue([]),
})) }))
import { Admin } from './Admin' import { Admin } from './Admin'
@@ -69,6 +70,19 @@ describe('Admin', () => {
) )
}) })
it('lists unclaimed boards and updates on unclaimed:update', async () => {
renderAdmin()
act(() => emit({ type: 'snapshot', teams: [], submissions: [], unclaimed: ['KIT-04', 'KIT-09'] }))
const panel = await screen.findByTestId('unclaimed-boards')
await waitFor(() => expect(within(panel).getByText('KIT-04')).toBeInTheDocument())
expect(within(panel).getByText('KIT-09')).toBeInTheDocument()
// a board gets claimed → it leaves the pool
act(() => emit({ type: 'unclaimed:update', kits: ['KIT-09'] }))
await waitFor(() => expect(within(panel).queryByText('KIT-04')).toBeNull())
expect(within(panel).getByText('KIT-09')).toBeInTheDocument()
})
it('gates behind the access code when none is stored', () => { it('gates behind the access code when none is stored', () => {
sessionStorage.clear() sessionStorage.clear()
renderAdmin() renderAdmin()
+30 -2
View File
@@ -16,7 +16,7 @@ function Stat({ label, value }: { label: string; value: number | string }) {
} }
function AdminBoard({ code }: { code: string }) { function AdminBoard({ code }: { code: string }) {
const { teams, submissions, nodes, activity, counts, status } = useCollective(code) const { teams, submissions, nodes, activity, counts, unclaimed, status } = useCollective(code)
const now = useNow() const now = useNow()
const connected = teams.filter((t) => t.deviceConnected).length const connected = teams.filter((t) => t.deviceConnected).length
const boardsLive = Object.values(nodes).filter(Boolean).length const boardsLive = Object.values(nodes).filter(Boolean).length
@@ -44,15 +44,43 @@ function AdminBoard({ code }: { code: string }) {
</header> </header>
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6"> <section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
<div className="grid grid-cols-2 md:grid-cols-6 gap-px bg-border rounded-md overflow-hidden"> <div className="grid grid-cols-2 md:grid-cols-7 gap-px bg-border rounded-md overflow-hidden">
<Stat label="Teams" value={teams.length} /> <Stat label="Teams" value={teams.length} />
<Stat label="Connected" value={connected} /> <Stat label="Connected" value={connected} />
<Stat label="Boards live" value={boardsLive} /> <Stat label="Boards live" value={boardsLive} />
<Stat label="Unclaimed" value={unclaimed.length} />
<Stat label="Flashes" value={totalFlashes} /> <Stat label="Flashes" value={totalFlashes} />
<Stat label="Submitted" value={submittedCount} /> <Stat label="Submitted" value={submittedCount} />
<Stat label="Judged" value={judgedCount} /> <Stat label="Judged" value={judgedCount} />
</div> </div>
<div className="border border-border rounded-md p-4 space-y-2" data-testid="unclaimed-boards">
<div className="flex items-center justify-between">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Unclaimed boards
</div>
<span className="font-mono text-[10px] text-muted-foreground">
powered on · waiting to be claimed
</span>
</div>
{unclaimed.length === 0 ? (
<p className="text-xs text-muted-foreground">
No unclaimed boards. Powered-on boards appear here until a team claims them.
</p>
) : (
<div className="flex flex-wrap gap-2">
{[...unclaimed].sort().map((kit) => (
<span
key={kit}
className="font-mono text-xs px-2 py-1 rounded-md border border-amber/40 bg-amber/5 text-foreground"
>
{kit}
</span>
))}
</div>
)}
</div>
<div className="border border-border rounded-md p-4 space-y-2"> <div className="border border-border rounded-md p-4 space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Board activity</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Board activity</div>
<BoardActivity activity={activity} nameFor={nameFor} /> <BoardActivity activity={activity} nameFor={nameFor} />
+3 -1
View File
@@ -55,9 +55,11 @@ export interface LeaderboardRow {
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] } | { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] }
| { type: 'team:update'; team: TeamSnapshot } | { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary } | { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number } | { type: 'score:new'; teamId: string; total: number }
| { type: 'node:status'; teamId: string; online: boolean } | { type: 'node:status'; teamId: string; online: boolean }
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string } | { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
// Kit ids of boards that have self-registered but aren't claimed yet.
| { type: 'unclaimed:update'; kits: string[] }