Compare commits

...
2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 6386884842 Merge feat/central-mode-and-judge-site: judge site tag + central-mode deploy wiring
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:38:40 -07:00
Omar SobhandClaude Opus 4.8 0adeb75236 feat(federation): site tag in judge queue + central-mode deploy wiring
Judge site label: SubmissionSummary now carries `site` (from the
submissions.site column, populated in listSubmissions + upsertSubmission
and rippled through the submission:new broadcast). SubmissionList shows
it under the team name so a central judge can tell which team stack a
submission came from; hidden on single-fleet deploys where site is ''.

Central-mode deploy: the existing cloud deploy already carries
FLEET_SECRET and the instance-ingest routes are unconditional, so it IS
the central control plane — no build flag needed. Fixed the reporter's
CENTRAL_API default to the real api ORIGIN (apess-api.redclaw.dev, not
the web host + /api proxy — the reporter calls server-to-server).
Documented the wiring: deploy/.env.example + a "Central control plane"
section in deploy/README.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:34:39 -07:00
9 changed files with 73 additions and 4 deletions
+3
View File
@@ -169,6 +169,9 @@ describe('collective API', () => {
.expect(201) .expect(201)
const teams = await request(central).get('/teams').set('X-Access-Code', ADMIN) const teams = await request(central).get('/teams').set('X-Access-Code', ADMIN)
expect(teams.body[0]).toMatchObject({ id: 'site-a:team-1', site: 'site-a' }) expect(teams.body[0]).toMatchObject({ id: 'site-a:team-1', site: 'site-a' })
// the judge queue summary carries the site so a central judge can disambiguate
const subs = await request(central).get('/submissions').set('X-Access-Code', JUDGE)
expect(subs.body[0]).toMatchObject({ teamId: 'site-a:team-1', site: 'site-a' })
}) })
}) })
}) })
+3 -2
View File
@@ -148,7 +148,7 @@ export function openStore(path = ':memory:'): Store {
add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site
`) `)
const listSubsStmt = db.prepare(` const listSubsStmt = db.prepare(`
SELECT s.team_id AS teamId, s.team_name AS teamName, s.submitted_at AS submittedAt, SELECT s.team_id AS teamId, s.team_name AS teamName, s.submitted_at AS submittedAt, s.site AS site,
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
FROM submissions s ORDER BY s.submitted_at FROM submissions s ORDER BY s.submitted_at
`) `)
@@ -219,7 +219,7 @@ export function openStore(path = ':memory:'): Store {
}) })
// mark the team's final phase complete if we know the team // mark the team's final phase complete if we know the team
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId) if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
return { teamId: s.teamId, teamName: s.teamName, submittedAt: s.submittedAt, scored: false } return { teamId: s.teamId, teamName: s.teamName, submittedAt: s.submittedAt, scored: false, site: s.site ?? '' }
}, },
listSubmissions() { listSubmissions() {
return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({ return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({
@@ -227,6 +227,7 @@ export function openStore(path = ':memory:'): Store {
teamName: r.teamName, teamName: r.teamName,
submittedAt: r.submittedAt, submittedAt: r.submittedAt,
scored: !!r.scored, scored: !!r.scored,
site: r.site ?? '',
})) }))
}, },
getSubmission(teamId) { getSubmission(teamId) {
+3
View File
@@ -57,6 +57,9 @@ export interface SubmissionSummary {
teamName: string teamName: string
submittedAt: string submittedAt: string
scored: boolean scored: boolean
/** Federation tag — the instance (site) this submission came from. '' on a
* single-fleet deploy; lets a central judge see which team stack it's from. */
site?: string
} }
export interface ScoreInput { export interface ScoreInput {
+13
View File
@@ -0,0 +1,13 @@
# Copy to .env (next to docker-compose.yml). Do NOT commit .env.
#
# This is the CLOUD / control-plane deploy (apess.redclaw.dev + apess-api.redclaw.dev
# behind traefik). It doubles as the CENTRAL control plane in a federated event:
# per-team local stacks (deploy/lan, --profile federated) report UP to it via the
# reporter sidecar, and their CENTRAL_API must point at this api's origin
# (https://apess-api.redclaw.dev) with the SAME FLEET_SECRET set here.
ADMIN_CODE=adm-xxxxxxxx
JUDGE_CODE=jdg-xxxxxxxx
# Shared federation secret: boards present it to /nodes/self-register AND local
# instances present it to /instances/register|heartbeat. Must match every
# board's apess-node.env and every reporter's FLEET_SECRET.
FLEET_SECRET=change-me
+24
View File
@@ -85,6 +85,30 @@ ssh [email protected] "cd ~/projects/apress && docker compose -f deploy/dock
# Remove DNS records if needed. # Remove DNS records if needed.
``` ```
## Central control plane (federated events)
For a multi-team event where each team runs its **own** local stack (`deploy/lan`,
with a board attached), this cloud deploy doubles as the **central control
plane** — the fleet dashboard + centralized judging. No separate build or flag:
the instance-ingest routes (`/instances/register`, `/instances/:id/heartbeat`,
`GET /instances`) and the `site`-tagged `PUT /teams/:id` / `POST /submissions`
are always present; the `Admin` dashboard switches to the grouped **fleet view**
automatically once instances phone home.
To wire a local team stack up to it:
1. This deploy must have `FLEET_SECRET` set (it already gates board self-register).
2. On each team's `deploy/lan` box, set in `.env`: a unique `SITE_ID`, the same
`FLEET_SECRET`, and `CENTRAL_API=https://apess-api.redclaw.dev` (the **api**
origin — the reporter calls it server-to-server, so no `/api` proxy).
3. Bring the team stack up with the reporter: `docker compose --env-file .env
-f docker-compose.yml --profile federated up -d --build`.
The reporter mirrors that instance's teams/submissions up (namespaced by
`SITE_ID`), and it appears in `/admin`'s fleet view with online/offline from its
heartbeat. Central is **observe-only** — it never touches a board (it can't reach
the NAT'd boards; only each local stack drives its own board).
## Why this lives on gw-03 ## Why this lives on gw-03
- 104 GB free disk, 16 GB Intel RAM, idle CPU - 104 GB free disk, 16 GB Intel RAM, idle CPU
+4 -2
View File
@@ -13,5 +13,7 @@ WEB_PORT=80
# SITE_ID must be unique per team/instance — it namespaces every id at central. # SITE_ID must be unique per team/instance — it namespaces every id at central.
SITE_ID=team-01 SITE_ID=team-01
SITE_NAME=Team 01 SITE_NAME=Team 01
# Central control-plane API base (its /api origin). # Central control-plane API ORIGIN (the api host itself, not the web host — the
CENTRAL_API=https://apess.redclaw.dev/api # reporter calls it server-to-server, so no /api proxy). Must run with the same
# FLEET_SECRET as below.
CENTRAL_API=https://apess-api.redclaw.dev
+12
View File
@@ -25,4 +25,16 @@ describe('SubmissionList', () => {
await user.click(screen.getByText('Bravo')) await user.click(screen.getByText('Bravo'))
expect(onSelect).toHaveBeenCalledWith('b') expect(onSelect).toHaveBeenCalledWith('b')
}) })
it('shows the site tag when present (central-mode judge) and omits it otherwise', () => {
const { rerender } = render(
<SubmissionList
items={[{ teamId: 'site-a:t1', teamName: 'Alpha', submittedAt: 'x', scored: false, site: 'site-a' }]}
onSelect={() => {}}
/>,
)
expect(screen.getByTestId('submission-site')).toHaveTextContent('site-a')
rerender(<SubmissionList items={items} onSelect={() => {}} />)
expect(screen.queryByTestId('submission-site')).toBeNull()
})
}) })
+8
View File
@@ -31,6 +31,14 @@ export function SubmissionList({ items, selectedId, onSelect }: SubmissionListPr
<span className="text-sm font-medium truncate">{s.teamName || s.teamId}</span> <span className="text-sm font-medium truncate">{s.teamName || s.teamId}</span>
{s.scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">scored</Badge>} {s.scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">scored</Badge>}
</div> </div>
{s.site && (
<div
data-testid="submission-site"
className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground truncate mt-0.5"
>
{s.site}
</div>
)}
</button> </button>
))} ))}
</div> </div>
+3
View File
@@ -41,6 +41,9 @@ export interface SubmissionSummary {
teamName: string teamName: string
submittedAt: string submittedAt: string
scored: boolean scored: boolean
/** Federation tag — the instance (site) this submission came from. '' on a
* single-fleet deploy; shown to a central judge to disambiguate teams. */
site?: string
} }
export interface ScoreInput { export interface ScoreInput {