Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6386884842 | ||
|
|
0adeb75236 |
@@ -169,6 +169,9 @@ describe('collective API', () => {
|
||||
.expect(201)
|
||||
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' })
|
||||
// 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
@@ -148,7 +148,7 @@ export function openStore(path = ':memory:'): Store {
|
||||
add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site
|
||||
`)
|
||||
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
|
||||
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
|
||||
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() {
|
||||
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,
|
||||
submittedAt: r.submittedAt,
|
||||
scored: !!r.scored,
|
||||
site: r.site ?? '',
|
||||
}))
|
||||
},
|
||||
getSubmission(teamId) {
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface SubmissionSummary {
|
||||
teamName: string
|
||||
submittedAt: string
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
@@ -85,6 +85,30 @@ ssh [email protected] "cd ~/projects/apress && docker compose -f deploy/dock
|
||||
# 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
|
||||
|
||||
- 104 GB free disk, 16 GB Intel RAM, idle CPU
|
||||
|
||||
@@ -13,5 +13,7 @@ WEB_PORT=80
|
||||
# SITE_ID must be unique per team/instance — it namespaces every id at central.
|
||||
SITE_ID=team-01
|
||||
SITE_NAME=Team 01
|
||||
# Central control-plane API base (its /api origin).
|
||||
CENTRAL_API=https://apess.redclaw.dev/api
|
||||
# Central control-plane API ORIGIN (the api host itself, not the web host — the
|
||||
# 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
|
||||
|
||||
@@ -25,4 +25,16 @@ describe('SubmissionList', () => {
|
||||
await user.click(screen.getByText('Bravo'))
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,14 @@ export function SubmissionList({ items, selectedId, onSelect }: SubmissionListPr
|
||||
<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>}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface SubmissionSummary {
|
||||
teamName: string
|
||||
submittedAt: string
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user