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:
co-authored by
Claude Opus 4.8
parent
8da6d1d7c1
commit
71a255cf15
@@ -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 })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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' } })
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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[] }
|
||||||
|
|||||||
Reference in New Issue
Block a user