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:
co-authored by
Claude Opus 4.8
parent
5ef14655b9
commit
45cc3a1f85
@@ -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
|
||||||
|
|||||||
@@ -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: [] })
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user