From 533f59adf9e84d81208109613b007795101fde44 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 11 Oct 2026 19:45:53 +0200 Subject: [PATCH] feat(settings): one public address (domain / DynDNS) for all mobile profiles Moves the public address from each driver's share panel to Settings -> System & backup, with Save and Detect public IP. The share panel shows the address in use and links to the setting; empty keeps using the local network address. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ README.md | 2 +- README.sv.md | 2 +- ROADMAP.md | 12 ++++++++ package-lock.json | 4 +-- package.json | 2 +- src/app.js | 14 ++++++++- src/public_url.js | 11 +++++++ src/settings.js | 40 +++++++++++++++++++++++++ src/styles.css | 5 ++++ src/training_views.js | 54 ++++++---------------------------- tests/browser_settings_tabs.js | 20 ++++++++++++- tests/browser_smoke.js | 2 +- 13 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 src/public_url.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb1b39..374f98b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.38.0 - 2026-10-11 + +### Changed + +- The public address for mobile profiles (a domain or DynDNS name, optionally with a port) is now one setting for the whole club, under **Settings → System & backup → Public address (domain / DynDNS)**, with Save and "Detect public IP". It used to be an edit field inside each driver's share panel, even though it was always shared. The share panel now shows the address in use and a "Change in Settings" button. Left empty, links and QR codes use the local network address as before. + ## 1.37.1 - 2026-10-11 ### Added diff --git a/README.md b/README.md index 2d4198f..46a38a4 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ Each registered driver can have an optional personal mobile link: 1. Open `Training` and select the driver. 2. Open `Goals and mobile profile`, then click `Enable mobile profile`. -3. For internet sharing, set the public base address, for example `https://racecore.example.org`, and click `Save address`. +3. For internet sharing, set the public address once for every driver under `Settings` -> `System & backup` -> `Public address (domain / DynDNS)`, for example `https://racecore.example.org` or `https://club.dyndns.org:8443`, and click `Save`. Each driver's share panel shows the address in use with a `Change in Settings` button. Leave it empty to use the local network address (same wifi only). 4. Scan `Local QR code` on the same network as RaceCore, or `Public QR code` over the internet. The local QR is generated from the RaceCore server LAN address, so it also works when the admin UI was opened as `localhost`. 5. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off. `Detect public IP` is an explicit suggestion only; use the FQDN when the TLS certificate is issued for the domain. diff --git a/README.sv.md b/README.sv.md index d6e529a..e41dc4e 100644 --- a/README.sv.md +++ b/README.sv.md @@ -303,7 +303,7 @@ Varje registrerad förare kan få en valfri personlig mobillänk: 1. Öppna `Träning` och välj föraren. 2. Öppna `Mål och mobilprofil` och klicka sedan `Aktivera mobilprofil`. -3. För internetdelning: ange publik basadress, exempelvis `https://racecore.example.org`, och klicka `Spara adress`. +3. För internetdelning: ange den publika adressen en gång för alla förare under `Inställningar` -> `System & backup` -> `Publik adress (domän / DynDNS)`, exempelvis `https://racecore.example.org` eller `https://klubben.dyndns.org:8443`, och klicka `Spara`. Varje förares delningspanel visar adressen som används och har knappen `Ändra under Inställningar`. Lämna tomt för att använda den lokala nätverksadressen (fungerar bara på samma wifi). 4. Skanna `Lokal QR-kod` på samma nätverk som RaceCore, eller `Publik QR-kod` via internet. Den lokala QR-koden skapas från RaceCore-serverns LAN-IP och fungerar därför även om adminvyn öppnades som `localhost`. 5. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen. Knappen `Hämta publik IP` är endast ett manuellt förslag; FQDN är rätt val när TLS-certifikatet använder domännamnet. diff --git a/ROADMAP.md b/ROADMAP.md index c744f00..9b13dd0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -892,6 +892,18 @@ Noticed while testing: opening Training stats with `?driver=` in the URL could l - The re-render after the load opens the driver. - The browser test opens the link first, before the page has ever seen the driver. It fails with the fix disabled and passes with it. 261 unit and 67 browser scenarios pass. +## Public address as one setting + +Asked by the user 2026-10-11: with a DynDNS name or a real domain it should be possible to enter it in settings, as an overall setting for every driver's mobile profile and not per driver. Otherwise it should work as now, using the local IP. + +Checked first: `settings.publicBaseUrl` already existed and was used by the profile link and the server's QR code, but it was only editable in each driver's share panel on Training stats. + +- [x] Settings → System & backup: the "Public address (domain / DynDNS)" section, with the field, Save and Detect public IP. `normalizePublicBaseUrl` moved to `src/public_url.js`, keeps the origin and the port, and refuses non-http(s). +- [x] The share panel shows the address in use and a "Change in Settings" button (`openSettingsTab("system", "settingsPublicAddress")`). The edit field there was removed. +- [x] README (sv/en) mobile profile steps updated. + +Verified 2026-10-11: the browser test refuses `ftp://nope`, saves `https://klubben.dyndns.org:8443/some/path` as `https://klubben.dyndns.org:8443`, shows it as in use and in a driver's share panel, follows "Change in Settings" to the System tab, and clears it again. 264 unit and 72 browser scenarios pass. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 5e54849..332fb12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.37.1", + "version": "1.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.37.1", + "version": "1.38.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index ee2d245..f3947b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.37.1", + "version": "1.38.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index 0f127f3..c02e558 100644 --- a/src/app.js +++ b/src/app.js @@ -657,7 +657,7 @@ const renderArchive = () => renderResultsArchiveView({ getSelectedSessionId: () => archiveSelectedSessionId, setSelectedSessionId: (value) => { archiveSelectedSessionId = value; }, getTrackLayoutLabel, }); -const renderTraining = () => renderTrainingView({ addTrackLayout, state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, countDriverExerciseSessions: countDriverExerciseSessionsLogic, countExerciseSessions: countExerciseSessionsLogic, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, isInitialLoadDone: () => initialLoadDone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); +const renderTraining = () => renderTrainingView({ addTrackLayout, state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, countDriverExerciseSessions: countDriverExerciseSessionsLogic, countExerciseSessions: countExerciseSessionsLogic, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, isInitialLoadDone: () => initialLoadDone, openPublicAddressSettings: () => { openSettingsTab("system", "settingsPublicAddress"); navigateToView("settings"); }, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); // Judge corrections are saved as usual and also pushed as this session's result, so the // server's "hot results" protection (live passings in the last 15 s) can't hold them back. const saveCorrectionFor = (session) => () => { @@ -794,6 +794,12 @@ const TRANSLATIONS = { "series.step3": "3 · Deltävlingar ({count})", "series.step4": "4 · Ställning", "series.classes_all": "Alla", + "settings.public_address_title": "Publik adress (domän / DynDNS)", + "settings.public_address_hint": "Gäller alla förares mobilprofiler: länkar och QR-koder byggs med den här adressen, t.ex. https://klubben.dyndns.org eller https://klubben.se:8443. Lämna tomt för att använda datorns adress i det lokala nätverket (fungerar bara på samma wifi).", + "settings.public_address_in_use": "Används nu: {url}", + "settings.public_address_local": "Ingen publik adress - länkar och QR-koder använder datorns lokala nätverksadress.", + "training.public_address_local": "lokala nätverket (ingen publik adress satt)", + "training.public_address_change": "Ändra under Inställningar", "series.round_participants": "Deltagare: {count} av {total} i klassen", "series.copy_previous": "Kopiera från förra", "series.no_previous_round": "Ingen tidigare deltävling i samma klass.", @@ -2592,6 +2598,12 @@ const TRANSLATIONS = { "series.step3": "3 · Rounds ({count})", "series.step4": "4 · Standings", "series.classes_all": "All", + "settings.public_address_title": "Public address (domain / DynDNS)", + "settings.public_address_hint": "Used for every driver's mobile profile: links and QR codes are built with this address, e.g. https://club.dyndns.org or https://club.example:8443. Leave empty to use this computer's local network address (works on the same wifi only).", + "settings.public_address_in_use": "In use: {url}", + "settings.public_address_local": "No public address - links and QR codes use this computer's local network address.", + "training.public_address_local": "the local network (no public address set)", + "training.public_address_change": "Change in Settings", "series.round_participants": "Drivers: {count} of {total} in the class", "series.copy_previous": "Copy from previous", "series.no_previous_round": "No earlier round in the same class.", diff --git a/src/public_url.js b/src/public_url.js new file mode 100644 index 0000000..66439b3 --- /dev/null +++ b/src/public_url.js @@ -0,0 +1,11 @@ +// The address phones and other networks use to reach this server (a domain or DynDNS name, +// optionally with a port), e.g. "https://club.dyndns.org:8443". Only the origin is kept; +// "" when empty or invalid - links and QR codes then use the local network address. +export function normalizePublicBaseUrl(value) { + try { + const url = new URL(String(value || "").trim()); + return ["http:", "https:"].includes(url.protocol) ? url.origin : ""; + } catch { + return ""; + } +} diff --git a/src/settings.js b/src/settings.js index 9226294..8f69fd4 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,3 +1,4 @@ +import { normalizePublicBaseUrl } from "./public_url.js"; import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js"; import { mountAuthSettings } from "./auth_view.js"; import { mountTtsSettings } from "./tts_settings_view.js"; @@ -449,6 +450,18 @@ export function renderSettings(deps) {
+
+

${t("settings.public_address_title")}

${t("settings.public_address_hint")}

+
+
+ + + +
+

${escapeHtml(state.settings.publicBaseUrl ? t("settings.public_address_in_use", { url: state.settings.publicBaseUrl }) : t("settings.public_address_local"))}

+
+
+

${t("settings.update_title")}

@@ -814,6 +827,33 @@ Erik Eriksson;654321;4WD;Associated;; }); }); + document.getElementById("settingsPublicBaseSave")?.addEventListener("click", async () => { + const input = document.getElementById("settingsPublicBaseUrl"); + const normalized = normalizePublicBaseUrl(input?.value); + if (input?.value.trim() && !normalized) { + document.getElementById("settingsPublicAddressStatus").textContent = t("training.public_url_invalid"); + return; + } + state.settings.publicBaseUrl = normalized; + saveState(); + await syncStateToBackend(); + renderView(); + }); + document.getElementById("settingsPublicAddressDetect")?.addEventListener("click", async () => { + const status = document.getElementById("settingsPublicAddressStatus"); + if (status) status.textContent = t("training.detecting_public_ip"); + try { + const response = await fetch("/api/network/public-address", { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + const input = document.getElementById("settingsPublicBaseUrl"); + if (input) input.value = payload.suggestedBaseUrl || ""; + if (status) status.textContent = t("training.detected_public_ip", { address: payload.address || "-" }); + } catch { + if (status) status.textContent = t("training.detect_public_ip_failed"); + } + }); + document.getElementById("settingsCheckUpdate")?.addEventListener("click", async () => { setUpdateNotice(""); await refreshUpdateStatus({ render: false, force: true }); diff --git a/src/styles.css b/src/styles.css index 950ee8f..afa9a8e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4621,3 +4621,8 @@ tr.is-removed td.lap-actions { .series-status-done { border-color: var(--success, #2e9d5b); } .series-status-qual_running, .series-status-finals { border-color: var(--accent); } @media (max-width: 900px) { .series-create { grid-template-columns: 1fr 1fr; } } + +/* Settings -> public address */ +.settings-public-address { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.settings-public-address input { flex: 1 1 320px; max-width: 520px; } +.training-public-address { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } diff --git a/src/training_views.js b/src/training_views.js index a50d6cd..0a8c294 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -3,6 +3,7 @@ import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_pro import { normalizeTrackLayouts } from "./track_layouts_logic.js"; import { renderTrackLayoutBar, bindTrackLayoutBar } from "./track_layout_bar.js"; import { buildTrainingRaceSessions } from "./training_logic.js"; +import { normalizePublicBaseUrl } from "./public_url.js"; // Which track layout the pace/record/consistency numbers are for: "current" (default), // "all" (overall), "none" (laps from before any layout existed), or a layout id. Module @@ -149,6 +150,7 @@ export function renderTrainingView(deps) { getSelectedClassId, setSelectedClassId, isInitialLoadDone = () => true, + openPublicAddressSettings = () => {}, } = deps; state.training = normalizeTrainingState(state.training); @@ -714,16 +716,11 @@ export function renderTrainingView(deps) { `).join("")} ` : ""} - -
- - -
-

+

+ ${escapeHtml(t("training.public_base_url"))}: + ${escapeHtml(publicBaseUrl || t("training.public_address_local"))} + +

${profileShare ? `