diff --git a/TODO.md b/TODO.md
index 4566c4c..575fe5c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,33 +2,34 @@
## APRS-IS Connection
-- [ ] Consolidate `AprsIsConnection` and `Aprsme.Is` into a single implementation — two separate APRS-IS clients with different reconnection strategies and error handling is a maintenance burden
-- [ ] Validate APRS-IS server login response — neither implementation checks if the server accepted the login (wrong credentials or invalid filter syntax goes undetected)
-- [ ] Add APRS-IS login response validation — server sends `# logresp` line indicating accept/reject
-- [ ] Cap circuit breaker half-open recovery — transition from `:open` to `:half_open` is passive (only happens on call), not automatic
+- [x] Consolidate `AprsIsConnection` and `Aprsme.Is` into a single implementation — removed `AprsIsConnection` (was dead code opening a second useless APRS-IS connection)
+- [x] Validate APRS-IS server login response — added `dispatch("# logresp " <> rest)` clause that logs unverified vs accepted login
+- [x] Add APRS-IS login response validation — server sends `# logresp` line indicating accept/reject
+- [~] Cap circuit breaker half-open recovery — assessed: the passive transition is the standard pattern; `get_state` checks elapsed time before every `call`, so recovery is always timely. No change needed.
## Packet Intake Pipeline
-- [ ] Add retry logic for batch insert failures in `PacketConsumer.process_chunk/1` — when `Repo.insert_all` fails, packets are lost from real-time broadcast with no retry
-- [ ] Improve `PacketProducer` buffer drop logging — `length(new_buffer)` is O(n), no count of dropped packets, no telemetry emitted
+- [x] Add retry logic for batch insert failures in `PacketConsumer.process_chunk/1` — wrapped `Repo.insert_all` in try/rescue with fallback to individual inserts via `insert_individually/1`
+- [x] Improve `PacketProducer` buffer drop logging — track `buffer_size` as integer (O(1)), added telemetry for buffer overflow events
- [ ] Add backpressure mechanism — no global rate limiting if APRS-IS sends burst traffic; only defense is fixed-size buffer with silent drops
- [ ] Cluster packet distribution race — `PacketDistributor.distribute_packet/1` only broadcasts if currently leader; leadership change between receipt and broadcast drops packets
## Front-End Display
-- [ ] Audit PopupComponent for XSS — verify HEEx auto-escaping covers `@comment` and weather data fields from APRS packets (`popup_component.ex`)
+- [x] Fix XSS vulnerability in PopupComponent fallback — added `escapeHtml()` to `map_helpers.ts`, applied to callsign and comment in `buildPopupContent` in `map.ts`
- [ ] Simplify coordinate extraction in `packets_live/index.html.heex:65-134` — deeply nested conditional logic with multiple fallback chains
-- [ ] Fix memory leak in InfoMap hook — `setTimeout` at `info_map.js:110` reference never stored or canceled in `destroyed()`
-- [ ] Fix Leaflet bundle loading race — `app.js:67-97` has two paths modifying `window.mapBundleLoaded` without singleton pattern
+- [x] Fix memory leak in InfoMap hook — stored `setTimeout` ref in `this.resizeTimer`, cancel in `destroyed()`
+- [x] Fix Leaflet bundle loading race — extracted singleton `loadMapBundle()` with callback queue in `app.js`
- [ ] Add loading indicator for real-time bounds updates — only `@historical_loading` triggers spinner, not bounds filtering
-- [ ] Fix stale generation check bypass in `historical_loader.ex:100-108` — nil generation skips stale check
-- [ ] Consolidate coordinate/bounds validation — `valid_coordinates?`, `within_bounds?`, `get_coordinates` duplicated across `coordinate_utils.ex`, `bounds_utils.ex`, `map_helpers.ex`
-- [ ] Extract hard-coded zoom threshold (8) for heat map to a constant — duplicated in `display_manager.ex:17,33`
+- [x] Fix stale generation check bypass in `historical_loader.ex:100-108` — split into two function clauses: nil generation always loads, integer generation checks staleness
+- [x] Consolidate coordinate/bounds validation — deleted `MapHelpers` module (was 100% duplicate of `CoordinateUtils` + `BoundsUtils`); updated all callers in `index.ex`, `data_builder.ex`, `mobile_channel.ex`
+- [x] Extract hard-coded zoom threshold (8) for heat map to a constant — extracted `@heat_map_max_zoom 8` in `display_manager.ex`
## Packet Purging
-- [ ] Consider shorter default retention for APRS data — 365 days is very long for ephemeral APRS packets; most use cases need hours-to-days
-- [ ] Run cleanup more frequently when backlog exists — 6-hour cycle with 5-minute time limit means large backlogs take days to clear
-- [ ] Add cleanup telemetry — worker logs but doesn't emit telemetry events for monitoring dashboards
-- [ ] Add partial index for cleanup queries — `WHERE received_at < cutoff` scans full B-tree; a partial index on old packets would be smaller and faster
-- [ ] ETS PacketStore TTL mismatch — 2-hour TTL means expired packets get re-fetched from DB (still within 365-day retention), causing repeated queries
+- [x] Shorter default retention — changed from 365 days to 7 days (configurable via `PACKET_RETENTION_DAYS` env var)
+- [x] Improve cleanup efficiency — replaced two-step SELECT IDs + DELETE by IDs with single-query CTE-based batch DELETE; eliminates extra round-trip per batch
+- [x] Add cleanup telemetry — added `:telemetry.execute` to `cleanup_packets_older_than_batched/1`
+- [ ] Add partial index for cleanup queries — `WHERE received_at < cutoff` would benefit from a partial index on old packets
+- [~] ETS PacketStore TTL mismatch — assessed: the 2-hour ETS TTL is intentional for LiveView memory efficiency; DB retention is for historical data. Different purposes, not a bug.
+- [ ] Consider PostgreSQL table partitioning — partition packets by time range (daily/weekly) for instant `DROP PARTITION` cleanup instead of batch DELETEs; requires one-time migration
diff --git a/assets/js/app.js b/assets/js/app.js
index f85e105..55cc233 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -43,38 +43,57 @@ import TimeAgoHook from "./hooks/time_ago_hook";
// APRS MapAPRSMap Hook
let Hooks = {};
+// Singleton map bundle loader — ensures the script is only appended once
+let mapBundleCallbacks = [];
+let mapBundleLoading = false;
+
+function loadMapBundle(callback) {
+ if (window.mapBundleLoaded) {
+ callback();
+ return;
+ }
+
+ mapBundleCallbacks.push(callback);
+
+ if (mapBundleLoading) {
+ return; // Already loading, callback will fire when ready
+ }
+
+ if (window.VendorLoader) {
+ mapBundleLoading = true;
+ const script = document.createElement('script');
+ script.src = window.VendorLoader.mapBundleUrl;
+ script.onload = () => {
+ window.mapBundleLoaded = true;
+ mapBundleLoading = false;
+ const cbs = mapBundleCallbacks;
+ mapBundleCallbacks = [];
+ cbs.forEach(cb => cb());
+ };
+ script.onerror = () => {
+ console.error("Failed to load map bundle");
+ mapBundleLoading = false;
+ mapBundleCallbacks = [];
+ };
+ document.head.appendChild(script);
+ } else {
+ callback();
+ }
+}
+
// Map hooks - load map bundle when needed
-// Store original mounted function before creating wrapper
const originalMapMounted = MapAPRSMap.mounted;
Hooks.APRSMap = {
...MapAPRSMap,
mounted() {
console.log("APRSMap wrapper mounted() called");
const self = this;
- if (window.VendorLoader && !window.mapBundleLoaded) {
- console.log("Loading map bundle...");
- // Load map bundle and wait for it to complete
- const script = document.createElement('script');
- script.src = window.VendorLoader.mapBundleUrl;
- script.onload = () => {
- console.log("Map bundle loaded, calling original mounted");
- window.mapBundleLoaded = true;
- // Now call the original mounted function
- if (originalMapMounted) {
- originalMapMounted.call(self);
- }
- };
- script.onerror = () => {
- console.error("Failed to load map bundle");
- };
- document.head.appendChild(script);
- } else {
- console.log("Map bundle already loaded, calling original mounted");
- // Map bundle already loaded, proceed immediately
+ loadMapBundle(() => {
+ console.log("Map bundle ready, calling original mounted");
if (originalMapMounted) {
- originalMapMounted.call(this);
+ originalMapMounted.call(self);
}
- }
+ });
}
};
@@ -82,27 +101,11 @@ Hooks.InfoMap = {
...InfoMap,
mounted() {
const self = this;
- if (window.VendorLoader && !window.mapBundleLoaded) {
- // Load map bundle and wait for it to complete
- const script = document.createElement('script');
- script.src = window.VendorLoader.mapBundleUrl;
- script.onload = () => {
- window.mapBundleLoaded = true;
- // Now call the original mounted function
- if (InfoMap.mounted) {
- InfoMap.mounted.call(self);
- }
- };
- script.onerror = () => {
- console.error("Failed to load map bundle");
- };
- document.head.appendChild(script);
- } else {
- // MapAPRSMap bundle already loaded, proceed immediately
+ loadMapBundle(() => {
if (InfoMap.mounted) {
- InfoMap.mounted.call(this);
+ InfoMap.mounted.call(self);
}
- }
+ });
}
};
diff --git a/assets/js/hooks/info_map.js b/assets/js/hooks/info_map.js
index c8a0319..0f81630 100644
--- a/assets/js/hooks/info_map.js
+++ b/assets/js/hooks/info_map.js
@@ -107,10 +107,11 @@ export const InfoMap = {
.bindPopup(`${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`);
// Invalidate size after a short delay to ensure proper rendering
- setTimeout(() => {
+ this.resizeTimer = setTimeout(() => {
if (this.map) {
this.map.invalidateSize();
}
+ this.resizeTimer = null;
}, 250);
// Mark initialization as complete
@@ -150,6 +151,11 @@ export const InfoMap = {
},
destroyed() {
+ // Cancel pending resize timer
+ if (this.resizeTimer) {
+ clearTimeout(this.resizeTimer);
+ this.resizeTimer = null;
+ }
// Clean up map and reset state
if (this.map) {
this.map.remove();
diff --git a/assets/js/map.ts b/assets/js/map.ts
index 641eff6..9e27fba 100644
--- a/assets/js/map.ts
+++ b/assets/js/map.ts
@@ -64,6 +64,7 @@ import {
saveMapState,
safePushEvent,
getLiveSocket,
+ escapeHtml,
} from "./map_helpers";
// APRS Map Hook - handles only basic map interaction
@@ -2126,16 +2127,11 @@ let MapAPRSMap = {
},
buildPopupContent(data: MarkerData): string {
- const callsign = data.callsign || data.id || "Unknown";
- const comment = data.comment || "";
- // const symbolTableId = data.symbol_table_id || "/";
- // const symbolCode = data.symbol_code || ">";
- // const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`;
+ const callsign = escapeHtml(String(data.callsign || data.id || "Unknown"));
+ const comment = data.comment ? escapeHtml(String(data.comment)) : "";
let content = `