feat(fleet): central fleet dashboard — instances → teams grouped by site

Phase 3 of the edge + control-plane architecture (frontend). The
instructor dashboard becomes fleet-aware: when local instances have
phoned home (central mode), it groups teams under the instance that
reported them, each with an online/offline dot (from heartbeat
last-seen) + team count; otherwise it renders the existing flat grid
unchanged (single-fleet deploys are unaffected).

- src/types.ts: mirror the api — `site?` on TeamSnapshot/SubmissionDTO,
  new InstanceDTO, `instance:update` event + `instances` on snapshot.
- api.ts: getInstances(code) (GET /instances; [] on single-fleet).
- useCollective: instances in state + reducer (snapshot seeds, instance:
  update upserts, an instance-less snapshot preserves known instances),
  polled in the REST seed without dropping the team seed on failure.
- Admin: FleetView (grouped) vs TeamGrid (flat), an Instances stat
  (live/total). Shared TeamGrid extracted from the old inline grid.

Judging already works centrally (submission ids are site-namespaced by
the reporter); deeper site-grouping in Judge + Landing-as-distribution
are deferred (plan's "refined as we go").

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 23:24:22 -07:00
co-authored by Claude Opus 4.8
parent 5ef14655b9
commit 45cc3a1f85
6 changed files with 230 additions and 23 deletions
+7
View File
@@ -5,6 +5,7 @@ import type {
ScoreInput, ScoreInput,
ScoreDTO, ScoreDTO,
LeaderboardRow, LeaderboardRow,
InstanceDTO,
WsEvent, WsEvent,
} from '@/types' } from '@/types'
@@ -82,6 +83,12 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard') return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
} }
/** Central-mode fleet view: the local instances that have phoned home. Empty on
* a single-fleet deploy (the route just returns []). */
export async function getInstances(code: string): Promise<InstanceDTO[]> {
return asJson(await fetch(`${API_BASE}/instances`, { headers: authHeaders(code) }), 'getInstances')
}
// --- board onboarding (participant) --------------------------------------- // --- board onboarding (participant) ---------------------------------------
export interface ClaimInput { export interface ClaimInput {
teamId: string teamId: string
+34 -1
View File
@@ -14,7 +14,15 @@ 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: {}, unclaimed: [] } const empty: CollectiveState = {
teams: {},
submissions: {},
nodes: {},
activity: [],
counts: {},
unclaimed: [],
instances: {},
}
describe('collectiveReducer', () => { describe('collectiveReducer', () => {
it('seeds from a snapshot', () => { it('seeds from a snapshot', () => {
@@ -39,6 +47,31 @@ describe('collectiveReducer', () => {
expect(next.unclaimed).toEqual(['KIT-07']) expect(next.unclaimed).toEqual(['KIT-07'])
}) })
it('seeds instances from a snapshot and upserts them on instance:update', () => {
const seeded = collectiveReducer(empty, {
type: 'snapshot',
teams: [],
submissions: [],
instances: [{ id: 'site-a', name: 'Team A', lastSeen: 't0' }],
})
expect(seeded.instances['site-a'].name).toBe('Team A')
const next = collectiveReducer(seeded, {
type: 'instance:update',
instance: { id: 'site-a', name: 'Team A', lastSeen: 't1' },
})
expect(next.instances['site-a'].lastSeen).toBe('t1')
})
it('a snapshot without instances preserves the ones already known', () => {
const seeded = collectiveReducer(empty, {
type: 'instance:update',
instance: { id: 'site-a', name: 'Team A', lastSeen: 't0' },
})
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
expect(next.instances['site-a']).toBeDefined() // not wiped by an instance-less snapshot
expect(Object.keys(next.teams)).toEqual(['a'])
})
it('preserves unclaimed when a snapshot omits it (REST seed)', () => { it('preserves unclaimed when a snapshot omits it (REST seed)', () => {
const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] }) const seeded = collectiveReducer(empty, { type: 'unclaimed:update', kits: ['KIT-01'] })
const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] }) const next = collectiveReducer(seeded, { type: 'snapshot', teams: [team('a')], submissions: [] })
+26 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useReducer, useRef, useState } from 'react' import { useEffect, useReducer, useRef, useState } from 'react'
import { openCollective, getTeams, getSubmissions, getUnclaimed } from './api' import { openCollective, getTeams, getSubmissions, getUnclaimed, getInstances } from './api'
import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types' import type { TeamSnapshot, SubmissionSummary, InstanceDTO, WsEvent, NodeActivityKind } from '@/types'
export interface NodeActivityEntry { export interface NodeActivityEntry {
teamId: string teamId: string
@@ -27,10 +27,20 @@ export interface CollectiveState {
counts: Record<string, NodeCounts> counts: Record<string, NodeCounts>
/** kit ids of powered-on boards no team has claimed yet. */ /** kit ids of powered-on boards no team has claimed yet. */
unclaimed: string[] unclaimed: string[]
/** siteId → federated local instance (central-mode; empty single-fleet). */
instances: Record<string, InstanceDTO>
} }
const MAX_ACTIVITY = 40 const MAX_ACTIVITY = 40
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {}, unclaimed: [] } const empty: CollectiveState = {
teams: {},
submissions: {},
nodes: {},
activity: [],
counts: {},
unclaimed: [],
instances: {},
}
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>> = {
@@ -46,10 +56,15 @@ 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, unclaimed: event.unclaimed ?? state.unclaimed } const instances = event.instances
? Object.fromEntries(event.instances.map((i) => [i.id, i]))
: state.instances
return { ...state, teams, submissions, unclaimed: event.unclaimed ?? state.unclaimed, instances }
} }
case 'unclaimed:update': case 'unclaimed:update':
return { ...state, unclaimed: event.kits } return { ...state, unclaimed: event.kits }
case 'instance:update':
return { ...state, instances: { ...state.instances, [event.instance.id]: event.instance } }
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':
@@ -96,6 +111,8 @@ export interface Collective {
activity: NodeActivityEntry[] activity: NodeActivityEntry[]
counts: Record<string, NodeCounts> counts: Record<string, NodeCounts>
unclaimed: string[] unclaimed: string[]
/** federated instances, sorted by name (central-mode; empty single-fleet). */
instances: InstanceDTO[]
status: CollectiveStatus status: CollectiveStatus
} }
@@ -117,7 +134,10 @@ export function useCollective(code: string): Collective {
const poll = async () => { const poll = async () => {
try { try {
const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)]) const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)])
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions }) // instances is central-mode only (empty/absent otherwise) — never let its
// failure drop the teams/submissions seed.
const instances = await getInstances(code).catch(() => undefined)
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions, instances })
} catch { } catch {
/* the WS snapshot may still arrive; leave state as-is */ /* the WS snapshot may still arrive; leave state as-is */
} }
@@ -159,6 +179,7 @@ export function useCollective(code: string): Collective {
activity: state.activity, activity: state.activity,
counts: state.counts, counts: state.counts,
unclaimed: state.unclaimed, unclaimed: state.unclaimed,
instances: Object.values(state.instances).sort((a, b) => a.name.localeCompare(b.name)),
status, status,
} }
} }
+25
View File
@@ -13,6 +13,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([]), getUnclaimed: vi.fn().mockResolvedValue([]),
getInstances: vi.fn().mockResolvedValue([]),
releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }), releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }),
})) }))
@@ -85,6 +86,30 @@ describe('Admin', () => {
expect(within(panel).getByText('KIT-09')).toBeInTheDocument() expect(within(panel).getByText('KIT-09')).toBeInTheDocument()
}) })
it('groups teams under their instance in central (federated) mode', async () => {
renderAdmin()
const a = { ...team(1), site: 'site-a' }
const b = { ...team(2), site: 'site-b' }
act(() =>
emit({
type: 'snapshot',
teams: [a, b],
submissions: [],
instances: [
{ id: 'site-a', name: 'Team A laptop', lastSeen: new Date().toISOString() },
{ id: 'site-b', name: 'Team B laptop', lastSeen: new Date().toISOString() },
],
}),
)
// fleet view replaces the flat grid; one section per instance
await waitFor(() => expect(screen.getByTestId('fleet-view')).toBeInTheDocument())
const sections = screen.getAllByTestId('fleet-instance')
expect(sections).toHaveLength(2)
const siteA = sections.find((s) => s.getAttribute('data-site') === 'site-a')!
expect(within(siteA).getByText('Team A laptop')).toBeInTheDocument()
expect(within(siteA).getAllByTestId('team-card')).toHaveLength(1)
})
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()
+117 -16
View File
@@ -3,10 +3,15 @@ import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate' import { AccessGate } from '@/components/AccessGate'
import { TeamCard } from '@/components/TeamCard' import { TeamCard } from '@/components/TeamCard'
import { BoardActivity } from '@/components/BoardActivity' import { BoardActivity } from '@/components/BoardActivity'
import { useCollective } from '@/lib/useCollective' import { useCollective, type Collective } from '@/lib/useCollective'
import { useNow } from '@/lib/useNow' import { useNow } from '@/lib/useNow'
import { releaseBoard } from '@/lib/api' import { releaseBoard } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TeamSnapshot } from '@/types'
/** A local instance is considered offline if it hasn't heartbeated recently
* (heartbeat cadence is 30s; allow ~2.5× before flagging it down). */
const INSTANCE_STALE_MS = 75_000
/** Release a mis-claimed / reassigned kit back to the pool so another team can /** Release a mis-claimed / reassigned kit back to the pool so another team can
* claim it. The collective WS pushes the refreshed unclaimed list automatically. */ * claim it. The collective WS pushes the refreshed unclaimed list automatically. */
@@ -62,8 +67,110 @@ function Stat({ label, value }: { label: string; value: number | string }) {
) )
} }
/** The team tiles for a set of teams (shared by the flat + grouped views). */
function TeamGrid({
teams,
submissions,
counts,
now,
}: {
teams: TeamSnapshot[]
submissions: Collective['submissions']
counts: Collective['counts']
now: number
}) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
submitted={!!submissions[t.id]}
scored={!!submissions[t.id]?.scored}
counts={counts[t.id]}
now={now}
/>
))}
</div>
)
}
/**
* Central-mode fleet view: teams grouped under the local instance (site) that
* reported them, each with an online/offline indicator + last-seen. Teams whose
* site matches no known instance fall into an "unlinked" group so nothing is
* hidden.
*/
function FleetView({
instances,
teams,
submissions,
counts,
now,
}: {
instances: Collective['instances']
teams: TeamSnapshot[]
submissions: Collective['submissions']
counts: Collective['counts']
now: number
}) {
const bySite = new Map<string, TeamSnapshot[]>()
for (const t of teams) {
const site = t.site || '—'
;(bySite.get(site) ?? bySite.set(site, []).get(site)!).push(t)
}
// instances first (in name order), then any orphan site buckets
const known = new Set(instances.map((i) => i.id))
const orphanSites = [...bySite.keys()].filter((s) => !known.has(s)).sort()
const Section = ({
id,
name,
lastSeen,
}: {
id: string
name: string
lastSeen?: string
}) => {
const group = bySite.get(id) ?? []
const online = lastSeen ? now - new Date(lastSeen).getTime() < INSTANCE_STALE_MS : false
const ageS = lastSeen ? Math.round((now - new Date(lastSeen).getTime()) / 1000) : null
return (
<div className="space-y-3" data-testid="fleet-instance" data-site={id}>
<div className="flex items-center gap-2 border-b border-border pb-1">
<span
className={cn('w-2 h-2 rounded-full shrink-0', lastSeen ? (online ? 'bg-teal animate-pulse' : 'bg-amber') : 'bg-muted-foreground')}
/>
<span className="font-mono text-xs font-semibold tracking-wide">{name}</span>
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{group.length} team{group.length === 1 ? '' : 's'}
{ageS !== null && ` · ${online ? 'live' : `${ageS}s ago`}`}
{!lastSeen && ' · unlinked'}
</span>
</div>
{group.length === 0 ? (
<p className="text-xs text-muted-foreground">No teams reported yet.</p>
) : (
<TeamGrid teams={group} submissions={submissions} counts={counts} now={now} />
)}
</div>
)
}
return (
<div className="space-y-8" data-testid="fleet-view">
{instances.map((i) => (
<Section key={i.id} id={i.id} name={i.name} lastSeen={i.lastSeen} />
))}
{orphanSites.map((s) => (
<Section key={s} id={s} name={s === '—' ? 'No site' : s} />
))}
</div>
)
}
function AdminBoard({ code }: { code: string }) { function AdminBoard({ code }: { code: string }) {
const { teams, submissions, nodes, activity, counts, unclaimed, status } = useCollective(code) const { teams, submissions, nodes, activity, counts, unclaimed, instances, 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
@@ -71,6 +178,8 @@ function AdminBoard({ code }: { code: string }) {
const submittedCount = Object.keys(submissions).length const submittedCount = Object.keys(submissions).length
const judgedCount = Object.values(submissions).filter((s) => s.scored).length const judgedCount = Object.values(submissions).filter((s) => s.scored).length
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
const federated = instances.length > 0 // central-mode: teams arrive tagged by site
const instancesLive = instances.filter((i) => now - new Date(i.lastSeen).getTime() < INSTANCE_STALE_MS).length
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
@@ -91,7 +200,8 @@ 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-7 gap-px bg-border rounded-md overflow-hidden"> <div className={cn('grid grid-cols-2 gap-px bg-border rounded-md overflow-hidden', federated ? 'md:grid-cols-8' : 'md:grid-cols-7')}>
{federated && <Stat label="Instances" value={`${instancesLive}/${instances.length}`} />}
<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} />
@@ -131,21 +241,12 @@ function AdminBoard({ code }: { code: string }) {
<BoardActivity activity={activity} nameFor={nameFor} /> <BoardActivity activity={activity} nameFor={nameFor} />
</div> </div>
{teams.length === 0 ? ( {teams.length === 0 && !federated ? (
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p> <p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
) : federated ? (
<FleetView instances={instances} teams={teams} submissions={submissions} counts={counts} now={now} />
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid"> <TeamGrid teams={teams} submissions={submissions} counts={counts} now={now} />
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
submitted={!!submissions[t.id]}
scored={!!submissions[t.id]?.scored}
counts={counts[t.id]}
now={now}
/>
))}
</div>
)} )}
</section> </section>
</main> </main>
+21 -1
View File
@@ -13,6 +13,9 @@ export interface TeamSnapshot {
stats: SessionStats stats: SessionStats
deviceConnected: boolean deviceConnected: boolean
updatedAt: string updatedAt: string
/** Federation tag: the local instance (site) this team belongs to. '' on a
* single-fleet deploy; set by the reporter sidecar on a central deploy. */
site?: string
} }
export interface SubmissionDTO { export interface SubmissionDTO {
@@ -21,6 +24,15 @@ export interface SubmissionDTO {
code: string code: string
add: AddLayers add: AddLayers
submittedAt: string submittedAt: string
/** Federation tag — see {@link TeamSnapshot.site}. */
site?: string
}
/** A running local (edge) stack registered with the central control plane. */
export interface InstanceDTO {
id: string
name: string
lastSeen: string
} }
/** Lightweight queue row for the judge (no full ADD payload). */ /** Lightweight queue row for the judge (no full ADD payload). */
@@ -60,7 +72,13 @@ export interface LeaderboardRow {
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback' export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] } | {
type: 'snapshot'
teams: TeamSnapshot[]
submissions: SubmissionSummary[]
unclaimed?: string[]
instances?: InstanceDTO[]
}
| { 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 }
@@ -68,3 +86,5 @@ export type WsEvent =
| { 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. // Kit ids of boards that have self-registered but aren't claimed yet.
| { type: 'unclaimed:update'; kits: string[] } | { type: 'unclaimed:update'; kits: string[] }
// A local instance (site) registered or heartbeated on the central plane.
| { type: 'instance:update'; instance: InstanceDTO }