fix live leaderbord details
This commit is contained in:
@@ -7,6 +7,7 @@ RC RaceCore is an RC timing and race-control system with support for sponsor eve
|
||||
## Architecture
|
||||
|
||||
- JavaScript module overview: `docs/javascript-architecture.md`
|
||||
- Security and performance audit: `docs/security-performance-audit.md`
|
||||
- Swedish operational guide: `README.sv.md`
|
||||
|
||||
## Screenshots
|
||||
@@ -51,6 +52,8 @@ If you want simple protection for public overlays, set this environment variable
|
||||
PUBLIC_OVERLAY_TOKEN=your-secret-token
|
||||
```
|
||||
|
||||
This token gates the overlay route only. Do not expose port `8081` directly to the internet because administration APIs are not authenticated. Use a trusted race LAN, VPN, or authenticated reverse proxy. If the UI is intentionally served from another origin, list it in `RC_ALLOWED_ORIGINS` (comma-separated). See [`docs/security-performance-audit.md`](docs/security-performance-audit.md).
|
||||
|
||||
Then the public overlay URL must include:
|
||||
|
||||
```text
|
||||
|
||||
@@ -5,6 +5,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
## Arkitektur
|
||||
|
||||
- JavaScript-modulöversikt: `docs/javascript-architecture.md`
|
||||
- Säkerhets- och prestandaaudit: `docs/security-performance-audit.md`
|
||||
- Primär engelsk README: `README.md`
|
||||
|
||||
## Skärmbilder
|
||||
@@ -49,6 +50,8 @@ Om du vill ha ett enkelt skydd för publika overlays, sätt denna miljövariabel
|
||||
PUBLIC_OVERLAY_TOKEN=din-hemliga-token
|
||||
```
|
||||
|
||||
Tokenen skyddar bara overlay-routen. Exponera inte port `8081` direkt mot internet eftersom administrations-API:erna saknar autentisering. Använd betrott race-LAN, VPN eller en autentiserad reverse proxy. Om webbgränssnittet avsiktligt körs från en annan origin anges den i `RC_ALLOWED_ORIGINS` (kommaseparerat). Se [`docs/security-performance-audit.md`](docs/security-performance-audit.md).
|
||||
|
||||
Då måste den publika overlay-länken innehålla:
|
||||
|
||||
```text
|
||||
|
||||
@@ -157,6 +157,7 @@ Runtime environment variables:
|
||||
- `RC_TIMING_DATA_DIR`: optional absolute/relative data directory override; defaults to `<repo>/data`
|
||||
- `RC_TIMING_PID_FILE`: optional PID-file path used by process control
|
||||
- `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes
|
||||
- `RC_ALLOWED_ORIGINS`: optional comma-separated cross-origin UI origins; same-origin is always allowed
|
||||
|
||||
### `server/public_training_profile.js`
|
||||
Role:
|
||||
|
||||
128
docs/security-performance-audit.md
Normal file
128
docs/security-performance-audit.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Security and Performance Audit
|
||||
|
||||
Audit date: 2026-08-28
|
||||
|
||||
## Scope
|
||||
|
||||
This review covers the Node/Express backend, SQLite persistence, browser runtime, decoder WebSocket processing, overlay refresh path, dependency tree, and bundled runtime assets. It is a source-level review plus the repository test and npm vulnerability scans. It is not a penetration test of a deployed network.
|
||||
|
||||
## Verification baseline
|
||||
|
||||
- `npm audit --omit=dev`: 0 known vulnerabilities across 105 production dependencies
|
||||
- `npm test`: 74 tests passing after the changes in this audit
|
||||
- `npm run verify`: Express and SQLite runtime verified
|
||||
- Runtime target: Node.js 22-26
|
||||
|
||||
## Changes applied during this audit
|
||||
|
||||
### Overlay rendering
|
||||
|
||||
The overlay previously rebuilt its complete HTML tree from three independent loops: backend hydration every 800 ms, live refresh every 250 ms, and the one-second session timer. This caused repeated leaderboard calculation, HTML generation, DOM replacement, image handling, and event-listener registration.
|
||||
|
||||
The live 250 ms path now updates only:
|
||||
|
||||
- elapsed/remaining clock nodes
|
||||
- predicted-lap text
|
||||
- predicted-lap progress width and status color
|
||||
|
||||
Full overlay rendering remains event-driven for passings and state changes, and follows backend hydration for remote overlay clients.
|
||||
|
||||
### Backend synchronization
|
||||
|
||||
The backend state poll now runs only while the current view is `Overlay`. It no longer hydrates the full application state every 800 ms while an operator edits Settings, Race Setup, Drivers, Cars, or other administration views.
|
||||
|
||||
### Decoder messages
|
||||
|
||||
Only recognized decoder passings that mutate race state now trigger state persistence and a view render. Status, keepalive, malformed, ignored, and duplicate channel messages no longer serialize the complete state or rebuild the active page.
|
||||
|
||||
### Cross-origin requests
|
||||
|
||||
The backend no longer returns wildcard CORS headers. Requests with an `Origin` header are accepted from the server's own origin or from origins explicitly listed in `RC_ALLOWED_ORIGINS`. Other origins receive `403`, including simple cross-origin POST requests that do not use a browser preflight.
|
||||
|
||||
Responses also include:
|
||||
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `Referrer-Policy: no-referrer`
|
||||
|
||||
## Security findings
|
||||
|
||||
### Critical: administration API has no authentication
|
||||
|
||||
All users that can reach port 8081 can currently read or replace application state and operate managed AMMC. The most sensitive endpoints are:
|
||||
|
||||
- `GET/POST /api/state`
|
||||
- `GET/POST /api/ammc/config`
|
||||
- `POST /api/ammc/start`
|
||||
- `POST /api/ammc/stop`
|
||||
- `POST /api/passings`
|
||||
- `POST /api/export/pdf`
|
||||
|
||||
AMMC configuration includes an executable path and arguments. Because the backend can start that executable, access to these endpoints must be treated as administrative host access.
|
||||
|
||||
Required deployment rule: do not expose port 8081 directly to the public internet. Keep it on a trusted race LAN, behind a VPN, or behind an authenticated reverse proxy.
|
||||
|
||||
Recommended code change: add authenticated operator sessions or an admin API token, protect every mutating/admin endpoint, and keep public timing endpoints separate.
|
||||
|
||||
### High: public overlay token does not protect the state API
|
||||
|
||||
`PUBLIC_OVERLAY_TOKEN` gates the public overlay HTML route, but the overlay still hydrates through the general `/api/state` endpoint. That endpoint contains full operational state and is not token-protected or sanitized.
|
||||
|
||||
Therefore `PUBLIC_OVERLAY_TOKEN` is only a route gate, not complete data/API security. Before publishing an overlay outside the race LAN, add a read-only, sanitized public-overlay state endpoint and make public overlays use only that endpoint.
|
||||
|
||||
### Medium: monolithic state import increases stored-XSS impact
|
||||
|
||||
Most dynamic UI values are escaped before insertion into HTML. However, imported JSON and unrestricted `/api/state` replacement can place attacker-controlled identifiers and configuration into many UI paths. Authentication and strict import schema validation should be the primary controls. A Content Security Policy should be added after inline styles have been removed or explicitly accounted for.
|
||||
|
||||
### Medium: large request bodies and no rate limiting
|
||||
|
||||
The global JSON limit is 20 MB and administrative endpoints have no request-rate control. This is acceptable only on a trusted LAN. Add per-route limits and rate limiting before any internet-facing deployment.
|
||||
|
||||
### Medium: bundled AMMC binaries are trusted artifacts
|
||||
|
||||
The repository includes large platform binaries and an archive. Installation currently verifies presence, not cryptographic hashes or signatures. Record vendor versions and expected SHA-256 hashes, then verify them during installation or release packaging.
|
||||
|
||||
### Low: health endpoint exposes the database path
|
||||
|
||||
`/api/health` returns the absolute SQLite path. This is useful for local diagnostics but unnecessary on a public deployment. Remove it or expose detailed health data only to authenticated operators.
|
||||
|
||||
## Performance findings
|
||||
|
||||
### High: monolithic state serialization and SQLite rewrite
|
||||
|
||||
`saveState()` serializes the complete application state into localStorage. Backend synchronization stores the same state as one large JSON document. `/api/passings` also parses and rewrites the state document to keep remote overlays current.
|
||||
|
||||
Cost grows with all historical sessions, results, passings, training records, and audio events. This is the main long-term scaling limit.
|
||||
|
||||
Recommended design:
|
||||
|
||||
1. Keep configuration and small UI state in `app_state`.
|
||||
2. Store sessions, competitors, results, passings, training laps, and audit events in normalized SQLite tables.
|
||||
3. Update only the active session result on a passing.
|
||||
4. Publish active-session deltas through WebSocket or Server-Sent Events instead of polling full state.
|
||||
|
||||
### Medium: leaderboard and track-record calculations repeat on each passing
|
||||
|
||||
A valid passing builds the leaderboard before and after mutation, scans prior class results for the track record, and can build it again during rendering. This is acceptable for roughly 10-12 cars but will become visible with larger endurance histories.
|
||||
|
||||
Recommended design: cache active-session leaderboard inputs and per-class track records; invalidate only the affected competitor and class.
|
||||
|
||||
### Medium: full view replacement on each valid passing
|
||||
|
||||
Timing and overlay views still replace substantial HTML after each valid passing. The 250 ms overlay animation path is now incremental, but passing-driven rendering remains full-page. Move leaderboard rows to keyed incremental updates if profiling shows frame drops during larger races.
|
||||
|
||||
### Low: source delivery is unbundled and unminified
|
||||
|
||||
ES modules are appropriate for the current local deployment. The size of `app.js` affects maintainability more than runtime by itself, but production releases would benefit from bundling, minification, hashed assets, and long-lived cache headers. Development currently uses `no-store` intentionally to support live file reload.
|
||||
|
||||
### Distribution size
|
||||
|
||||
The AMMC archive and multi-platform binaries dominate repository and deployment size. Build platform-specific release archives instead of syncing all AMMC targets to each timing computer.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Add admin authentication and a sanitized public overlay API.
|
||||
2. Replace full-state overlay polling with server-pushed active-session deltas.
|
||||
3. Normalize active race results and training history into SQLite tables.
|
||||
4. Add request limits, rate limiting, import schema validation, and CSP.
|
||||
5. Add AMMC checksum verification and platform-specific release packaging.
|
||||
6. Profile a simulated 12-car race and an endurance dataset before deeper leaderboard optimization.
|
||||
@@ -19,6 +19,7 @@
|
||||
"status": "node scripts/serverctl.js status",
|
||||
"restart": "node scripts/serverctl.js restart",
|
||||
"verify": "node scripts/verify-install.js",
|
||||
"audit:prod": "npm audit --omit=dev",
|
||||
"test": "node --experimental-default-type=module tests/run.js",
|
||||
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
|
||||
},
|
||||
|
||||
24
server.js
24
server.js
@@ -59,13 +59,33 @@ const ammcState = {
|
||||
};
|
||||
|
||||
const app = express();
|
||||
const allowedCorsOrigins = new Set(
|
||||
String(process.env.RC_ALLOWED_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim().replace(/\/$/, ""))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: "20mb" }));
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
|
||||
const requestOrigin = `${req.protocol}://${req.get("host")}`;
|
||||
const origin = String(req.get("origin") || "").replace(/\/$/, "");
|
||||
const corsAllowed = !origin || origin === requestOrigin || allowedCorsOrigins.has(origin);
|
||||
if (!corsAllowed) {
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
if (origin) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(204).end();
|
||||
res.status(corsAllowed ? 204 : 403).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
|
||||
46
src/app.js
46
src/app.js
@@ -140,7 +140,7 @@ const checkAppVersion = () => checkAppVersionHelper({ getBackendUrl, getBaseline
|
||||
const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } });
|
||||
const startOverlaySync = () => startOverlaySyncHelper({ getOverlaySyncTimer: () => overlaySyncTimer, setOverlaySyncTimer: (value) => { overlaySyncTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView, renderView });
|
||||
const startOverlayRotation = () => startOverlayRotationHelper({ getOverlayRotationTimer: () => overlayRotationTimer, setOverlayRotationTimer: (value) => { overlayRotationTimer = value; }, getOverlayRotationIndex: () => overlayRotationIndex, setOverlayRotationIndex: (value) => { overlayRotationIndex = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderView });
|
||||
const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderOverlay });
|
||||
const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, refreshOverlayDynamics });
|
||||
const ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } });
|
||||
const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext });
|
||||
const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext });
|
||||
@@ -180,7 +180,7 @@ const startSessionWithAudio = (session, callbacks) => {
|
||||
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || ""));
|
||||
return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice });
|
||||
};
|
||||
const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, updateHeaderState });
|
||||
const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, refreshTimingDynamics, updateHeaderState });
|
||||
const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis });
|
||||
const connectDecoder = () => connectDecoderHelper({
|
||||
state,
|
||||
@@ -239,6 +239,48 @@ const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter
|
||||
const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar });
|
||||
const renderGuide = () => renderGuideView({ dom, t });
|
||||
const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl });
|
||||
const refreshOverlayDynamics = () => {
|
||||
const active = getActiveSession();
|
||||
if (!active) return;
|
||||
|
||||
const timing = getSessionTiming(active);
|
||||
const clock = timing.followUpActive
|
||||
? formatCountdown(timing.followUpRemainingMs)
|
||||
: timing.untimed
|
||||
? formatElapsedClock(timing.elapsedMs)
|
||||
: formatCountdown(timing.remainingMs);
|
||||
document.querySelectorAll("[data-overlay-clock]").forEach((node) => {
|
||||
node.textContent = clock;
|
||||
});
|
||||
|
||||
const rowsByKey = new Map(buildLeaderboard(active).map((row) => [String(row.key), row]));
|
||||
document.querySelectorAll("[data-overlay-competitor]").forEach((node) => {
|
||||
const row = rowsByKey.get(String(node.dataset.overlayCompetitor || ""));
|
||||
if (!row) return;
|
||||
const prediction = node.querySelector("[data-overlay-prediction]");
|
||||
const fill = node.querySelector("[data-overlay-prediction-fill]");
|
||||
if (prediction) prediction.textContent = formatPredictedLapDelta(row.predictedRemainingMs);
|
||||
if (fill instanceof HTMLElement) {
|
||||
fill.style.width = `${Math.max(0, Math.min(100, Math.round(Math.min(1, row.predictedProgress || 0) * 100)))}%`;
|
||||
fill.classList.remove("overlay-prediction-good", "overlay-prediction-warn", "overlay-prediction-overdue");
|
||||
fill.classList.add(`overlay-prediction-${row.predictionTone || "good"}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
const refreshTimingDynamics = () => {
|
||||
const active = getActiveSession();
|
||||
if (!active) return;
|
||||
|
||||
const timing = getSessionTiming(active);
|
||||
const clock = timing.followUpActive
|
||||
? formatCountdown(timing.followUpRemainingMs)
|
||||
: timing.untimed
|
||||
? formatElapsedClock(timing.elapsedMs)
|
||||
: formatCountdown(timing.remainingMs);
|
||||
document.querySelectorAll("[data-timing-clock]").forEach((node) => {
|
||||
node.textContent = clock;
|
||||
});
|
||||
};
|
||||
const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } });
|
||||
const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } });
|
||||
const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid });
|
||||
|
||||
@@ -177,15 +177,11 @@ function connectDecoderRole(role, deps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((message) => processDecoderMessage(message, role));
|
||||
} else {
|
||||
processDecoderMessage(parsed, role);
|
||||
}
|
||||
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const changed = messages.reduce((didChange, message) => processDecoderMessage(message, role) || didChange, false);
|
||||
|
||||
saveState();
|
||||
updateConnectionBadge();
|
||||
if (["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
|
||||
if (changed && ["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
|
||||
renderView();
|
||||
}
|
||||
};
|
||||
@@ -330,7 +326,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`);
|
||||
triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
|
||||
saveState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decoderRole === "pit_exit") {
|
||||
@@ -360,7 +356,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`);
|
||||
triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
|
||||
saveState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decoderRole === "split") {
|
||||
@@ -392,7 +388,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
});
|
||||
pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`);
|
||||
saveState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (startMode === "staggered" && !entry.startTimestamp) {
|
||||
@@ -400,7 +396,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
entry.lastTimestamp = timestamp;
|
||||
announcePassing(entry);
|
||||
saveState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!entry.startTimestamp) {
|
||||
@@ -448,7 +444,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
}
|
||||
persistPassingToBackend(session.id, passing);
|
||||
saveState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousPersonalBest = Number(entry.bestLapMs || 0) || null;
|
||||
@@ -500,4 +496,6 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
}
|
||||
}
|
||||
announcePassing(entry);
|
||||
saveState();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export function renderOverlayPageView(deps) {
|
||||
</div>
|
||||
<div class="overlay-meta">
|
||||
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
|
||||
${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock">${overlayClock}</div>`}
|
||||
${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock" data-overlay-clock>${overlayClock}</div>`}
|
||||
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
|
||||
</div>
|
||||
</header>`}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
|
||||
.map((row, idx) => {
|
||||
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
||||
return `
|
||||
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}">
|
||||
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}" data-overlay-competitor="${escapeHtml(row.key)}">
|
||||
<div class="overlay-race-pos">
|
||||
<span class="pos-pill ${posClass}">${idx + 1}</span>
|
||||
</div>
|
||||
@@ -77,10 +77,10 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
|
||||
<div class="overlay-prediction">
|
||||
<div class="overlay-prediction-meta">
|
||||
<label>${t("overlay.next_predicted_lap")}</label>
|
||||
<span>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
|
||||
<span data-overlay-prediction>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
|
||||
</div>
|
||||
<div class="overlay-prediction-track">
|
||||
<div class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((Math.min(1, row.predictedProgress || 0)) * 100)))}%"></div>
|
||||
<div data-overlay-prediction-fill class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((Math.min(1, row.predictedProgress || 0)) * 100)))}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +223,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
|
||||
</div>
|
||||
${obsConfig.showClock ? `
|
||||
<div class="overlay-obs-clockblock">
|
||||
<strong>${elapsedOrRemaining}</strong>
|
||||
<strong data-overlay-clock>${elapsedOrRemaining}</strong>
|
||||
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
|
||||
</div>
|
||||
` : ""}
|
||||
@@ -247,7 +247,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
|
||||
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
|
||||
<h2>${escapeHtml(active.name)}</h2>
|
||||
</div>
|
||||
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock">${elapsedOrRemaining}</strong>` : ""}
|
||||
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock" data-overlay-clock>${elapsedOrRemaining}</strong>` : ""}
|
||||
</div>
|
||||
<div class="overlay-obs-lowerthird-rows">
|
||||
${compactRows.slice(0, 6).map((row, idx) => {
|
||||
@@ -317,7 +317,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
|
||||
<h2>${escapeHtml(active.name)}</h2>
|
||||
</div>
|
||||
</div>
|
||||
${obsConfig.showClock ? `<div class="overlay-obs-tower-clock">${elapsedOrRemaining}</div>` : ""}
|
||||
${obsConfig.showClock ? `<div class="overlay-obs-tower-clock" data-overlay-clock>${elapsedOrRemaining}</div>` : ""}
|
||||
</div>
|
||||
<div class="overlay-obs-tower-meta">
|
||||
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
|
||||
|
||||
@@ -235,8 +235,9 @@ export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPol
|
||||
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
|
||||
clearInterval(getOverlaySyncTimer());
|
||||
setOverlaySyncTimer(setInterval(async () => {
|
||||
if (getCurrentView() !== "overlay") return;
|
||||
if (!getLocalStateDirty()) await hydrateFromBackend();
|
||||
if (getCurrentView() === "overlay") renderView();
|
||||
renderView();
|
||||
}, 800));
|
||||
}
|
||||
|
||||
@@ -248,10 +249,10 @@ export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlay
|
||||
}, 8000));
|
||||
}
|
||||
|
||||
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, renderOverlay }) {
|
||||
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, refreshOverlayDynamics }) {
|
||||
clearInterval(getOverlayLiveRefreshTimer());
|
||||
setOverlayLiveRefreshTimer(setInterval(() => {
|
||||
if (getCurrentView() === "overlay" && ["leaderboard", "tv", "team"].includes(getOverlayViewMode())) renderOverlay();
|
||||
if (getCurrentView() === "overlay" && ["leaderboard", "speaker", "results", "tv", "team", "obs"].includes(getOverlayViewMode())) refreshOverlayDynamics();
|
||||
}, 250));
|
||||
}
|
||||
|
||||
@@ -418,12 +419,15 @@ export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSessio
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, updateHeaderState }) {
|
||||
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, refreshTimingDynamics = () => {}, updateHeaderState }) {
|
||||
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
|
||||
const timerState = handleSessionTimerTick();
|
||||
const active = getActiveSession();
|
||||
if (getCurrentView() === "timing" && active && (active.status === "running" || timerState.changed)) renderView();
|
||||
if (getCurrentView() === "timing" && active) {
|
||||
if (timerState.changed) renderView();
|
||||
else if (active.status === "running") refreshTimingDynamics();
|
||||
}
|
||||
if (getCurrentView() === "dashboard" && timerState.changed) renderView();
|
||||
if (getOverlayMode() && getCurrentView() === "overlay" && active) renderView();
|
||||
if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView();
|
||||
if (timerState.changed) updateHeaderState();
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ function renderRecentPassings(session, deps) {
|
||||
}
|
||||
|
||||
export function renderTimingView(deps) {
|
||||
const leaderboardModalScrollTop = document.querySelector("#leaderboardModalOverlay .modal-card")?.scrollTop || 0;
|
||||
const {
|
||||
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, renderTable, uid,
|
||||
getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings,
|
||||
@@ -369,7 +370,7 @@ export function renderTimingView(deps) {
|
||||
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
|
||||
</div>
|
||||
<div class="timing-session-stats">
|
||||
<article class="timing-session-stat"><span>${clockLabel}</span><strong>${clockValue}</strong></article>
|
||||
<article class="timing-session-stat"><span>${clockLabel}</span><strong data-timing-clock>${clockValue}</strong></article>
|
||||
<article class="timing-session-stat"><span>${t("timing.started")}</span><strong>${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</strong></article>
|
||||
<article class="timing-session-stat"><span>${t("table.start_mode")}</span><strong>${escapeHtml(getStartModeLabel(active.startMode))}</strong></article>
|
||||
<article class="timing-session-stat"><span>${t("timing.seeding_mode")}</span><strong>${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}</strong></article>
|
||||
@@ -440,6 +441,11 @@ export function renderTimingView(deps) {
|
||||
${selectedRow && active ? renderLeaderboardModal(active, selectedRow, { ...helperDeps, renderQuickAddActions: (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps) }) : ""}
|
||||
`;
|
||||
|
||||
if (selectedRow && leaderboardModalScrollTop > 0) {
|
||||
const modalCard = document.querySelector("#leaderboardModalOverlay .modal-card");
|
||||
if (modalCard instanceof HTMLElement) modalCard.scrollTop = leaderboardModalScrollTop;
|
||||
}
|
||||
|
||||
document.getElementById("timingConnect")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("timingWsUrl");
|
||||
if (input && input.value.trim()) {
|
||||
@@ -573,7 +579,7 @@ export function renderTimingView(deps) {
|
||||
if (!tp) {
|
||||
return;
|
||||
}
|
||||
processDecoderMessage({
|
||||
const changed = processDecoderMessage({
|
||||
msg: "PASSING",
|
||||
transponder: tp,
|
||||
rtc_time: new Date().toISOString(),
|
||||
@@ -581,6 +587,7 @@ export function renderTimingView(deps) {
|
||||
resend: false,
|
||||
loop_id: "sim",
|
||||
});
|
||||
if (changed) renderView();
|
||||
});
|
||||
|
||||
document.getElementById("setActiveSession")?.addEventListener("click", () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ function createHarness() {
|
||||
|
||||
test("split decoder records intermediate time without changing main lap state", () => {
|
||||
const harness = createHarness();
|
||||
harness.passing(17_000, "main");
|
||||
assert.equal(harness.passing(17_000, "main"), true);
|
||||
const result = harness.state.resultsBySession.session_1;
|
||||
const entry = result.competitors.team_team_1;
|
||||
assert.equal(entry.laps, 1);
|
||||
@@ -150,3 +150,13 @@ test("connect decoder opens one websocket per enabled role", () => {
|
||||
globalThis.WebSocket = OriginalWebSocket;
|
||||
}
|
||||
});
|
||||
|
||||
test("non-passing decoder messages do not persist race state", () => {
|
||||
const harness = createHarness();
|
||||
const changed = processDecoderMessageHelper({ msg: "STATUS", decoder: "online" }, {
|
||||
state: harness.state,
|
||||
getActiveSession: () => harness.session,
|
||||
});
|
||||
assert.equal(Boolean(changed), false);
|
||||
assert.equal(harness.getSaveCount(), 0);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyPersistedStateHelper,
|
||||
hydrateFromBackendHelper,
|
||||
syncStateToBackendHelper,
|
||||
tickClockHelper,
|
||||
} from "../src/runtime_services.js";
|
||||
|
||||
function withMockFetch(mock, callback) {
|
||||
@@ -284,3 +285,29 @@ test("failed state sync leaves version and dirty state unchanged", async () => {
|
||||
assert.equal(backend.available, false);
|
||||
assert.equal(backend.lastError, "error.sync_failed:connection refused");
|
||||
});
|
||||
|
||||
test("running timing clock updates without rebuilding the leaderboard details modal", () => {
|
||||
let renders = 0;
|
||||
let dynamicRefreshes = 0;
|
||||
let headerRefreshes = 0;
|
||||
const base = {
|
||||
dom: { clock: { textContent: "" } },
|
||||
currentLanguage: () => "sv",
|
||||
getActiveSession: () => ({ status: "running" }),
|
||||
getCurrentView: () => "timing",
|
||||
getOverlayMode: () => false,
|
||||
renderView: () => { renders += 1; },
|
||||
refreshTimingDynamics: () => { dynamicRefreshes += 1; },
|
||||
updateHeaderState: () => { headerRefreshes += 1; },
|
||||
};
|
||||
|
||||
tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: false }) });
|
||||
assert.equal(dynamicRefreshes, 1);
|
||||
assert.equal(renders, 0);
|
||||
assert.equal(headerRefreshes, 0);
|
||||
|
||||
tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: true }) });
|
||||
assert.equal(dynamicRefreshes, 1);
|
||||
assert.equal(renders, 1);
|
||||
assert.equal(headerRefreshes, 1);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
|
||||
PORT: String(port),
|
||||
RC_TIMING_DATA_DIR: dataDir,
|
||||
RC_TIMING_PID_FILE: "",
|
||||
RC_ALLOWED_ORIGINS: "https://trusted.example",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -87,6 +88,20 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
|
||||
assert.equal(health.ok, true);
|
||||
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
|
||||
|
||||
const sameOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: baseUrl } });
|
||||
assert.equal(sameOriginResponse.status, 200);
|
||||
assert.equal(sameOriginResponse.headers.get("access-control-allow-origin"), baseUrl);
|
||||
|
||||
const allowedOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: "https://trusted.example" } });
|
||||
assert.equal(allowedOriginResponse.status, 200);
|
||||
assert.equal(allowedOriginResponse.headers.get("access-control-allow-origin"), "https://trusted.example");
|
||||
|
||||
const rejectedOriginResponse = await fetch(`${baseUrl}/api/ammc/start`, {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://untrusted.example" },
|
||||
});
|
||||
assert.equal(rejectedOriginResponse.status, 403);
|
||||
|
||||
const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`);
|
||||
const soundManifest = await soundManifestResponse.json();
|
||||
assert.equal(soundManifestResponse.status, 200);
|
||||
|
||||
Reference in New Issue
Block a user