diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 4fbb671..8802642 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -247,6 +247,10 @@ pub struct HttpFrontendConfig { pub spectrum_usable_span_ratio: f32, /// Whether to expose the RF Gain control in the web UI. pub show_sdr_gain_control: bool, + /// Default decode history retention in minutes for the active rig. + pub decode_history_retention_min: u64, + /// Optional per-rig decode history retention overrides in minutes. + pub decode_history_retention_min_by_rig: HashMap, /// Authentication settings pub auth: HttpAuthConfig, } @@ -262,6 +266,8 @@ impl Default for HttpFrontendConfig { spectrum_coverage_margin_hz: 50_000, spectrum_usable_span_ratio: 0.92, show_sdr_gain_control: true, + decode_history_retention_min: 24 * 60, + decode_history_retention_min_by_rig: HashMap::new(), auth: HttpAuthConfig::default(), } } @@ -388,6 +394,25 @@ impl ClientConfig { "[frontends.http].spectrum_usable_span_ratio must be > 0.0 and <= 1.0".to_string(), ); } + if self.frontends.http.decode_history_retention_min == 0 { + return Err( + "[frontends.http].decode_history_retention_min must be > 0".to_string(), + ); + } + for (rig_id, minutes) in &self.frontends.http.decode_history_retention_min_by_rig { + if rig_id.trim().is_empty() { + return Err( + "[frontends.http].decode_history_retention_min_by_rig keys must not be empty" + .to_string(), + ); + } + if *minutes == 0 { + return Err(format!( + "[frontends.http].decode_history_retention_min_by_rig[\"{}\"] must be > 0", + rig_id + )); + } + } if self.frontends.rigctl.enabled && self.frontends.rigctl.rig_ports.is_empty() { return Err( "[frontends.rigctl].rig_ports must contain at least one rig when enabled" @@ -487,6 +512,8 @@ impl ClientConfig { spectrum_coverage_margin_hz: 50_000, spectrum_usable_span_ratio: 0.92, show_sdr_gain_control: true, + decode_history_retention_min: 24 * 60, + decode_history_retention_min_by_rig: HashMap::new(), auth: HttpAuthConfig { enabled: false, rx_passphrase: Some("rx-passphrase-example".to_string()), @@ -588,13 +615,24 @@ mod tests { assert_eq!(config.frontends.http.initial_map_zoom, 10); assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000); assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92); + assert_eq!(config.frontends.http.decode_history_retention_min, 1440); + assert!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .is_empty() + ); assert_eq!(config.frontends.rigctl.port, 4532); assert!(config.frontends.http_json.enabled); assert_eq!(config.frontends.http_json.port, 0); assert!(config.remote.url.is_none()); assert!(config.general.website_url.is_none()); assert!(config.general.website_name.is_none()); - assert!(config.general.ais_vessel_url_base.is_none()); + assert_eq!( + config.general.ais_vessel_url_base, + Some("https://www.vesselfinder.com/?mmsi=".to_string()) + ); assert_eq!(config.remote.poll_interval_ms, 750); assert!(config.frontends.audio.enabled); assert_eq!(config.frontends.audio.server_port, 4531); @@ -626,6 +664,11 @@ port = 8080 initial_map_zoom = 12 spectrum_coverage_margin_hz = 40000 spectrum_usable_span_ratio = 0.9 +decode_history_retention_min = 720 + +[frontends.http.decode_history_retention_min_by_rig] +vhf = 180 +uhf = 60 "#; @@ -648,6 +691,23 @@ spectrum_usable_span_ratio = 0.9 assert_eq!(config.frontends.http.initial_map_zoom, 12); assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 40_000); assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.9); + assert_eq!(config.frontends.http.decode_history_retention_min, 720); + assert_eq!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .get("vhf"), + Some(&180) + ); + assert_eq!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .get("uhf"), + Some(&60) + ); } #[test] diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index f1e73cd..84fd996 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -183,6 +183,10 @@ async fn async_init() -> DynResult { cfg.frontends.http.spectrum_coverage_margin_hz; frontend_runtime.http_spectrum_usable_span_ratio = cfg.frontends.http.spectrum_usable_span_ratio; + frontend_runtime.http_decode_history_retention_min = + cfg.frontends.http.decode_history_retention_min; + frontend_runtime.http_decode_history_retention_min_by_rig = + cfg.frontends.http.decode_history_retention_min_by_rig.clone(); // Resolve remote URL: CLI > config [remote] section > error let remote_url = cli diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 8a6a33f..d02540a 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -236,6 +236,10 @@ pub struct FrontendRuntimeContext { pub http_spectrum_coverage_margin_hz: u32, /// Fraction of the sampled spectrum span treated as usable by the web UI. pub http_spectrum_usable_span_ratio: f32, + /// Default decode history retention in minutes. + pub http_decode_history_retention_min: u64, + /// Per-rig decode history retention overrides in minutes. + pub http_decode_history_retention_min_by_rig: HashMap, /// Currently selected remote rig id (used by remote client routing). pub remote_active_rig_id: Arc>>, /// Cached remote rig list from GetRigs polling. @@ -296,6 +300,8 @@ impl FrontendRuntimeContext { http_initial_map_zoom: 10, http_spectrum_coverage_margin_hz: 50_000, http_spectrum_usable_span_ratio: 0.92, + http_decode_history_retention_min: 24 * 60, + http_decode_history_retention_min_by_rig: HashMap::new(), remote_active_rig_id: Arc::new(Mutex::new(None)), remote_rigs: Arc::new(Mutex::new(Vec::new())), owner_callsign: None, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 486a5c5..4ac5a39 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -347,10 +347,27 @@ const headerRigSwitchSelect = document.getElementById("header-rig-switch-select" const headerStylePickSelect = document.getElementById("header-style-pick-select"); const rdsPsOverlay = document.getElementById("rds-ps-overlay"); let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000)); +let decodeHistoryRetentionMin = 24 * 60; let primaryRds = null; let vchanRdsById = new Map(); let rdsOverlayEntries = []; +function currentDecodeHistoryRetentionMs() { + const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || (24 * 60))); + return minutes * 60 * 1000; +} + +window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; + +window.applyDecodeHistoryRetention = function() { + if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); + if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); + if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); + if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); + if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); + if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); +}; + function syncTopBarAccess() { const loggedOut = authEnabled && !authRole; const tabBar = document.getElementById("tab-bar"); @@ -2441,6 +2458,19 @@ function render(update) { ) { spectrumUsableSpanRatio = Math.max(0.01, Math.min(1.0, Number(update.spectrum_usable_span_ratio))); } + if ( + typeof update.decode_history_retention_min === "number" && + Number.isFinite(update.decode_history_retention_min) && + update.decode_history_retention_min > 0 + ) { + const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); + if (nextRetentionMin !== decodeHistoryRetentionMin) { + decodeHistoryRetentionMin = nextRetentionMin; + if (typeof window.applyDecodeHistoryRetention === "function") { + window.applyDecodeHistoryRetention(); + } + } + } scheduleSpectrumLayout(); updateTitle(); updateFooterBuildInfo(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js index d4e27ee..e42fa05 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js @@ -8,7 +8,6 @@ const aisBarOverlay = document.getElementById("ais-bar-overlay"); const aisChannelSummaryEl = document.getElementById("ais-channel-summary"); const aisVesselCountEl = document.getElementById("ais-vessel-count"); const aisLatestSeenEl = document.getElementById("ais-latest-seen"); -const AIS_MAX_MESSAGES = 200; const AIS_BAR_WINDOW_MS = 15 * 60 * 1000; const AIS_DEFAULT_A_HZ = 161_975_000; const AIS_CHANNEL_SPACING_HZ = 50_000; @@ -17,6 +16,17 @@ let aisMessageHistory = []; let aisPaused = false; let aisBufferedWhilePaused = 0; +function currentAisHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" + ? window.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneAisMessageHistory() { + const cutoffMs = Date.now() - currentAisHistoryRetentionMs(); + aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); +} + function scheduleAisUi(key, job) { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob(key, job); @@ -295,6 +305,7 @@ window.resetAisHistoryView = function() { }; function renderAisHistory() { + pruneAisMessageHistory(); if (!aisMessagesEl || aisPaused) { updateAisSummary(); return; @@ -317,7 +328,7 @@ function addAisMessage(msg) { }); aisMessageHistory.unshift(msg); - if (aisMessageHistory.length > AIS_MAX_MESSAGES) aisMessageHistory.length = AIS_MAX_MESSAGES; + pruneAisMessageHistory(); scheduleAisBarUpdate(); if (aisPaused) { @@ -332,6 +343,12 @@ function addAisMessage(msg) { } } +window.pruneAisHistoryView = function() { + pruneAisMessageHistory(); + updateAisBar(); + renderAisHistory(); +}; + if (aisClearBtn) { aisClearBtn.addEventListener("click", async () => { try { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js index ec40b8a..0158a3c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js @@ -10,7 +10,6 @@ const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); const aprsTotalCountEl = document.getElementById("aprs-total-count"); const aprsVisibleCountEl = document.getElementById("aprs-visible-count"); const aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); -const APRS_MAX_PACKETS = 100; const APRS_BAR_WINDOW_MS = 15 * 60 * 1000; let aprsFilterText = ""; let aprsPacketHistory = []; @@ -21,6 +20,17 @@ let aprsHideCrc = false; let aprsCollapseDup = false; let aprsTypeFilter = "all"; +function currentAprsHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" + ? window.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneAprsPacketHistory() { + const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); + aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); +} + function scheduleAprsUi(key, job) { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob(key, job); @@ -304,6 +314,7 @@ function renderAprsRow(pkt, isFresh) { } function renderAprsHistory() { + pruneAprsPacketHistory(); if (!aprsPacketsEl || aprsPaused) { updateAprsSummary(); updateAprsChipState(); @@ -359,13 +370,19 @@ window.resetAprsHistoryView = function() { if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs"); }; +window.pruneAprsHistoryView = function() { + pruneAprsPacketHistory(); + updateAprsBar(); + renderAprsHistory(); +}; + function addAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); aprsPacketHistory.unshift(pkt); - if (aprsPacketHistory.length > APRS_MAX_PACKETS) aprsPacketHistory.length = APRS_MAX_PACKETS; + pruneAprsPacketHistory(); if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js index 6fcae63..e3c5511 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js @@ -5,7 +5,6 @@ const ft8MessagesEl = document.getElementById("ft8-messages"); const ft8FilterInput = document.getElementById("ft8-filter"); const ft8PauseBtn = document.getElementById("ft8-pause-btn"); const ft8BarOverlay = document.getElementById("ft8-bar-overlay"); -const FT8_MAX_MESSAGES = 200; const FT8_BAR_WINDOW_MS = 15 * 60 * 1000; const FT8_PERIOD_SECONDS = 15; let ft8FilterText = ""; @@ -13,6 +12,17 @@ let ft8MessageHistory = []; let ft8Paused = false; let ft8BufferedWhilePaused = 0; +function currentFt8HistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" + ? window.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneFt8MessageHistory() { + const cutoffMs = Date.now() - currentFt8HistoryRetentionMs(); + ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); +} + function scheduleFt8Ui(key, job) { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob(key, job); @@ -78,6 +88,7 @@ function updateFt8PauseUi() { } function renderFt8History() { + pruneFt8MessageHistory(); if (!ft8MessagesEl || ft8Paused) { updateFt8PauseUi(); return; @@ -91,8 +102,9 @@ function renderFt8History() { } function addFt8Message(msg) { + msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); ft8MessageHistory.unshift(msg); - if (ft8MessageHistory.length > FT8_MAX_MESSAGES) ft8MessageHistory.length = FT8_MAX_MESSAGES; + pruneFt8MessageHistory(); scheduleFt8BarUpdate(); if (ft8Paused) { ft8BufferedWhilePaused += 1; @@ -102,6 +114,12 @@ function addFt8Message(msg) { scheduleFt8HistoryRender(); } +window.pruneFt8HistoryView = function() { + pruneFt8MessageHistory(); + updateFt8Bar(); + renderFt8History(); +}; + function ft8BarRfText(msg) { const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); if (!Number.isFinite(displayFreqHz)) return null; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/hf-aprs.js index aeaa7f7..51d5a86 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/hf-aprs.js @@ -9,7 +9,6 @@ const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn") const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count"); const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count"); const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen"); -const HF_APRS_MAX_PACKETS = 100; let hfAprsFilterText = ""; let hfAprsPacketHistory = []; let hfAprsPaused = false; @@ -19,6 +18,17 @@ let hfAprsHideCrc = false; let hfAprsCollapseDup = false; let hfAprsTypeFilter = "all"; +function currentHfAprsHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" + ? window.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneHfAprsPacketHistory() { + const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs(); + hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); +} + function scheduleHfAprsHistoryRender() { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory()); @@ -296,6 +306,7 @@ function renderHfAprsRow(pkt, isFresh) { } function renderHfAprsHistory() { + pruneHfAprsPacketHistory(); if (!hfAprsPacketsEl || hfAprsPaused) { updateHfAprsSummary(); updateHfAprsChipState(); @@ -318,13 +329,18 @@ window.resetHfAprsHistoryView = function() { renderHfAprsHistory(); }; +window.pruneHfAprsHistoryView = function() { + pruneHfAprsPacketHistory(); + renderHfAprsHistory(); +}; + function addHfAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); hfAprsPacketHistory.unshift(pkt); - if (hfAprsPacketHistory.length > HF_APRS_MAX_PACKETS) hfAprsPacketHistory.length = HF_APRS_MAX_PACKETS; + pruneHfAprsPacketHistory(); if (hfAprsPaused) { hfAprsBufferedWhilePaused += 1; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vdes.js index b9420aa..027b253 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vdes.js @@ -8,13 +8,23 @@ const vdesBarOverlay = document.getElementById("vdes-bar-overlay"); const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); const vdesFrameCountEl = document.getElementById("vdes-frame-count"); const vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); -const VDES_MAX_MESSAGES = 200; const VDES_BAR_WINDOW_MS = 15 * 60 * 1000; let vdesFilterText = ""; let vdesMessageHistory = []; let vdesPaused = false; let vdesBufferedWhilePaused = 0; +function currentVdesHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" + ? window.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneVdesMessageHistory() { + const cutoffMs = Date.now() - currentVdesHistoryRetentionMs(); + vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); +} + function scheduleVdesUi(key, job) { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob(key, job); @@ -60,6 +70,7 @@ function vdesHexPreview(rawBytes) { } function updateVdesSummary() { + pruneVdesMessageHistory(); if (vdesChannelSummaryEl) { vdesChannelSummaryEl.textContent = currentVdesCenterText(); } @@ -228,6 +239,7 @@ window.resetVdesHistoryView = function() { }; function renderVdesHistory() { + pruneVdesMessageHistory(); if (!vdesMessagesEl || vdesPaused) { updateVdesSummary(); return; @@ -250,7 +262,7 @@ function addVdesMessage(msg) { }); vdesMessageHistory.unshift(msg); - if (vdesMessageHistory.length > VDES_MAX_MESSAGES) vdesMessageHistory.length = VDES_MAX_MESSAGES; + pruneVdesMessageHistory(); scheduleVdesBarUpdate(); if (vdesPaused) { @@ -323,4 +335,10 @@ window.onServerVdes = function(msg) { } }; +window.pruneVdesHistoryView = function() { + pruneVdesMessageHistory(); + updateVdesBar(); + renderVdesHistory(); +}; + updateVdesSummary(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js index 4141639..709b30f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js @@ -4,13 +4,23 @@ const wsprPeriodEl = document.getElementById("wspr-period"); const wsprMessagesEl = document.getElementById("wspr-messages"); const wsprFilterInput = document.getElementById("wspr-filter"); const wsprPauseBtn = document.getElementById("wspr-pause-btn"); -const WSPR_MAX_MESSAGES = 200; const WSPR_PERIOD_SECONDS = 120; let wsprFilterText = ""; let wsprMessageHistory = []; let wsprPaused = false; let wsprBufferedWhilePaused = 0; +function currentWsprHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" + ? window.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneWsprMessageHistory() { + const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); + wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); +} + function scheduleWsprHistoryRender() { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory()); @@ -59,6 +69,7 @@ function updateWsprPauseUi() { } function renderWsprHistory() { + pruneWsprMessageHistory(); if (!wsprMessagesEl || wsprPaused) { updateWsprPauseUi(); return; @@ -72,8 +83,9 @@ function renderWsprHistory() { } function addWsprMessage(msg) { + msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); wsprMessageHistory.unshift(msg); - if (wsprMessageHistory.length > WSPR_MAX_MESSAGES) wsprMessageHistory.length = WSPR_MAX_MESSAGES; + pruneWsprMessageHistory(); if (wsprPaused) { wsprBufferedWhilePaused += 1; updateWsprPauseUi(); @@ -82,6 +94,11 @@ function addWsprMessage(msg) { scheduleWsprHistoryRender(); } +window.pruneWsprHistoryView = function() { + pruneWsprMessageHistory(); + renderWsprHistory(); +}; + function escapeWsprHtml(input) { return input .replaceAll("&", "&") diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 63db9a2..1972c61 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -82,6 +82,7 @@ struct FrontendMeta { initial_map_zoom: u8, spectrum_coverage_margin_hz: u32, spectrum_usable_span_ratio: f32, + decode_history_retention_min: u64, } #[get("/status")] @@ -127,6 +128,10 @@ fn inject_frontend_meta(json: &str, meta: FrontendMeta) -> String { extra.insert("initial_map_zoom".into(), serde_json::json!(meta.initial_map_zoom)); extra.insert("spectrum_coverage_margin_hz".into(), serde_json::json!(meta.spectrum_coverage_margin_hz)); extra.insert("spectrum_usable_span_ratio".into(), serde_json::json!(meta.spectrum_usable_span_ratio)); + extra.insert( + "decode_history_retention_min".into(), + serde_json::json!(meta.decode_history_retention_min), + ); // Serialize the extra map, strip its outer braces, and splice in. let extra_json = match serde_json::to_string(&extra) { @@ -156,6 +161,7 @@ fn frontend_meta_from_context( initial_map_zoom: initial_map_zoom_from_context(context), spectrum_coverage_margin_hz: spectrum_coverage_margin_hz_from_context(context), spectrum_usable_span_ratio: spectrum_usable_span_ratio_from_context(context), + decode_history_retention_min: decode_history_retention_min_from_context(context), } } @@ -217,6 +223,24 @@ fn spectrum_usable_span_ratio_from_context(context: &FrontendRuntimeContext) -> context.http_spectrum_usable_span_ratio } +fn decode_history_retention_min_from_context(context: &FrontendRuntimeContext) -> u64 { + let default_minutes = context.http_decode_history_retention_min.max(1); + let Some(active_rig_id) = context + .remote_active_rig_id + .lock() + .ok() + .and_then(|v| v.clone()) + else { + return default_minutes; + }; + context + .http_decode_history_retention_min_by_rig + .get(&active_rig_id) + .copied() + .filter(|minutes| *minutes > 0) + .unwrap_or(default_minutes) +} + #[get("/events")] pub async fn events( state: web::Data>, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs index 15dd9f6..6e273fc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs @@ -27,13 +27,6 @@ use trx_core::decode::{ }; use trx_frontend::FrontendRuntimeContext; -const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); -/// Maximum number of raw AIS messages kept in the ring buffer. -/// AIS vessels can transmit every 2 s, so without a cap the buffer grows -/// unboundedly. 10 000 entries covers ~100 active vessels at 2-second intervals -/// for ~3 minutes — enough for a realistic snapshot while bounding memory use. -const AIS_HISTORY_MAX: usize = 10_000; - fn current_timestamp_ms() -> i64 { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -42,36 +35,68 @@ fn current_timestamp_ms() -> i64 { i64::try_from(millis).unwrap_or(i64::MAX) } -fn prune_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) { +fn decode_history_retention(context: &FrontendRuntimeContext) -> Duration { + let default_minutes = context.http_decode_history_retention_min.max(1); + let minutes = context + .remote_active_rig_id + .lock() + .ok() + .and_then(|v| v.clone()) + .and_then(|rig_id| { + context + .http_decode_history_retention_min_by_rig + .get(&rig_id) + .copied() + }) + .filter(|minutes| *minutes > 0) + .unwrap_or(default_minutes); + Duration::from_secs(minutes.saturating_mul(60)) +} + +fn decode_history_cutoff(context: &FrontendRuntimeContext) -> Instant { + Instant::now() - decode_history_retention(context) +} + +fn prune_aprs_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, AprsPacket)>) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); } } -fn prune_hf_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) { +fn prune_hf_aprs_history( + context: &FrontendRuntimeContext, + history: &mut VecDeque<(Instant, AprsPacket)>, +) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); } } -fn prune_ais_history(history: &mut VecDeque<(Instant, AisMessage)>) { +fn prune_ais_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, AisMessage)>) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); } } -fn prune_vdes_history(history: &mut VecDeque<(Instant, VdesMessage)>) { +fn prune_vdes_history( + context: &FrontendRuntimeContext, + history: &mut VecDeque<(Instant, VdesMessage)>, +) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); @@ -87,10 +112,7 @@ fn record_ais(context: &FrontendRuntimeContext, mut msg: AisMessage) { .lock() .expect("ais history mutex poisoned"); history.push_back((Instant::now(), msg)); - prune_ais_history(&mut history); - if history.len() > AIS_HISTORY_MAX { - history.pop_front(); - } + prune_ais_history(context, &mut history); } fn record_vdes(context: &FrontendRuntimeContext, mut msg: VdesMessage) { @@ -102,30 +124,36 @@ fn record_vdes(context: &FrontendRuntimeContext, mut msg: VdesMessage) { .lock() .expect("vdes history mutex poisoned"); history.push_back((Instant::now(), msg)); - prune_vdes_history(&mut history); + prune_vdes_history(context, &mut history); } -fn prune_cw_history(history: &mut VecDeque<(Instant, CwEvent)>) { +fn prune_cw_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, CwEvent)>) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); } } -fn prune_ft8_history(history: &mut VecDeque<(Instant, Ft8Message)>) { +fn prune_ft8_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, Ft8Message)>) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); } } -fn prune_wspr_history(history: &mut VecDeque<(Instant, WsprMessage)>) { +fn prune_wspr_history( + context: &FrontendRuntimeContext, + history: &mut VecDeque<(Instant, WsprMessage)>, +) { + let cutoff = decode_history_cutoff(context); while let Some((ts, _)) = history.front() { - if ts.elapsed() <= HISTORY_RETENTION { + if *ts >= cutoff { break; } history.pop_front(); @@ -141,7 +169,7 @@ fn record_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) { .lock() .expect("aprs history mutex poisoned"); history.push_back((Instant::now(), pkt)); - prune_aprs_history(&mut history); + prune_aprs_history(context, &mut history); } fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) { @@ -153,7 +181,7 @@ fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) { .lock() .expect("hf_aprs history mutex poisoned"); history.push_back((Instant::now(), pkt)); - prune_hf_aprs_history(&mut history); + prune_hf_aprs_history(context, &mut history); } fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) { @@ -162,7 +190,7 @@ fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) { .lock() .expect("cw history mutex poisoned"); history.push_back((Instant::now(), event)); - prune_cw_history(&mut history); + prune_cw_history(context, &mut history); } fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) { @@ -171,7 +199,7 @@ fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) { .lock() .expect("ft8 history mutex poisoned"); history.push_back((Instant::now(), msg)); - prune_ft8_history(&mut history); + prune_ft8_history(context, &mut history); } fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) { @@ -180,7 +208,7 @@ fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) { .lock() .expect("wspr history mutex poisoned"); history.push_back((Instant::now(), msg)); - prune_wspr_history(&mut history); + prune_wspr_history(context, &mut history); } pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec { @@ -188,7 +216,7 @@ pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec Vec Vec .ais_history .lock() .expect("ais history mutex poisoned"); - prune_ais_history(&mut history); + prune_ais_history(context, &mut history); // Iterate oldest-first; later entries overwrite earlier ones so the // HashMap always holds the newest message per MMSI. let mut latest: HashMap = HashMap::new(); @@ -230,7 +258,7 @@ pub fn snapshot_vdes_history(context: &FrontendRuntimeContext) -> Vec Vec { .cw_history .lock() .expect("cw history mutex poisoned"); - prune_cw_history(&mut history); + prune_cw_history(context, &mut history); history.iter().map(|(_, evt)| evt.clone()).collect() } @@ -248,7 +276,7 @@ pub fn snapshot_ft8_history(context: &FrontendRuntimeContext) -> Vec .ft8_history .lock() .expect("ft8 history mutex poisoned"); - prune_ft8_history(&mut history); + prune_ft8_history(context, &mut history); history.iter().map(|(_, msg)| msg.clone()).collect() } @@ -257,7 +285,7 @@ pub fn snapshot_wspr_history(context: &FrontendRuntimeContext) -> Vec