fix: performance improvements and bug fixes across backend and frontend
Backend: - Use indexed has_weather boolean for weather queries instead of 6 OR conditions - Enable gzip compression for static assets - Re-enable security headers (CSP, X-Frame-Options, X-Content-Type-Options) - Supervise cleanup task via BroadcastTaskSupervisor instead of Task.start - Relax IsSupervisor restart limits (5/60s) to survive transient network issues - Remove dead code: empty cache invalidation block, commented-out logging Frontend: - Use callsignIndex for O(1) marker dedup instead of O(n) forEach scan - Consolidate triple packet loop into single pass in new_packets handler - Batch trail visualization updates via requestAnimationFrame - Replace DOM querySelectorAll z-index scan with simple counter - Only rebuild OMS markers when crossing clustering zoom threshold - Clean up trail manager on hook destroy - Remove dead commented-out localStorage code
This commit is contained in:
parent
f5cc0b8c2b
commit
ac42896271
9 changed files with 85 additions and 97 deletions
|
|
@ -31,6 +31,8 @@ export class TrailManager {
|
||||||
private trailDuration: number; // in milliseconds
|
private trailDuration: number; // in milliseconds
|
||||||
private maxTrails: number = 500; // Maximum number of trails to keep in memory
|
private maxTrails: number = 500; // Maximum number of trails to keep in memory
|
||||||
private maxPositionsPerTrail: number = 1000; // Maximum positions per trail
|
private maxPositionsPerTrail: number = 1000; // Maximum positions per trail
|
||||||
|
private pendingTrailUpdates: Map<string, boolean> = new Map(); // callsign -> isHistorical
|
||||||
|
private trailUpdateRafId: number | null = null;
|
||||||
// Colors ordered to maximize contrast between consecutive assignments.
|
// Colors ordered to maximize contrast between consecutive assignments.
|
||||||
// Avoids yellows, oranges, and grays that blend with roads on map tiles.
|
// Avoids yellows, oranges, and grays that blend with roads on map tiles.
|
||||||
private colorPalette: string[] = [
|
private colorPalette: string[] = [
|
||||||
|
|
@ -207,9 +209,31 @@ export class TrailManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always update trail visualization, with immediate=true for historical dots
|
// Defer trail visualization to batch multiple updates into one frame
|
||||||
// This ensures historical trails are immediately visible when loaded all at once
|
this.scheduleTrailUpdate(baseCallsign, isHistoricalDot);
|
||||||
this.updateTrailVisualization(baseCallsign, trailState, isHistoricalDot);
|
}
|
||||||
|
|
||||||
|
private scheduleTrailUpdate(baseCallsign: string, isHistorical: boolean) {
|
||||||
|
// Track which trails need updating; preserve isHistorical=true if set
|
||||||
|
const existing = this.pendingTrailUpdates.get(baseCallsign);
|
||||||
|
this.pendingTrailUpdates.set(baseCallsign, existing || isHistorical);
|
||||||
|
|
||||||
|
if (this.trailUpdateRafId === null) {
|
||||||
|
this.trailUpdateRafId = requestAnimationFrame(() => {
|
||||||
|
this.flushPendingTrailUpdates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushPendingTrailUpdates() {
|
||||||
|
this.trailUpdateRafId = null;
|
||||||
|
for (const [callsign, isHistorical] of this.pendingTrailUpdates) {
|
||||||
|
const trailState = this.trails.get(callsign);
|
||||||
|
if (trailState) {
|
||||||
|
this.updateTrailVisualization(callsign, trailState, isHistorical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pendingTrailUpdates.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractBaseCallsign(markerId: string | number): string {
|
private extractBaseCallsign(markerId: string | number): string {
|
||||||
|
|
@ -532,6 +556,14 @@ export class TrailManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.trailUpdateRafId !== null) {
|
||||||
|
cancelAnimationFrame(this.trailUpdateRafId);
|
||||||
|
this.trailUpdateRafId = null;
|
||||||
|
}
|
||||||
|
this.pendingTrailUpdates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
cleanupOldPositions() {
|
cleanupOldPositions() {
|
||||||
// Don't clean up historical positions - only clean up very old live positions
|
// Don't clean up historical positions - only clean up very old live positions
|
||||||
const veryOldCutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours
|
const veryOldCutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
|
||||||
|
|
@ -582,14 +582,13 @@ let MapAPRSMap = {
|
||||||
? Math.abs(currentZoom - self.lastZoom)
|
? Math.abs(currentZoom - self.lastZoom)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Handle OMS markers when crossing zoom threshold
|
// Only rebuild OMS when crossing the clustering threshold
|
||||||
if (self.oms) {
|
const wasUnclustered = self.lastZoom !== undefined && self.lastZoom >= DISABLE_CLUSTERING_AT_ZOOM;
|
||||||
// Clear and re-add all markers to OMS on zoom change
|
const isUnclustered = currentZoom >= DISABLE_CLUSTERING_AT_ZOOM;
|
||||||
|
if (self.oms && (wasUnclustered !== isUnclustered || self.lastZoom === undefined)) {
|
||||||
self.oms.clearMarkers();
|
self.oms.clearMarkers();
|
||||||
self.markers.forEach((marker, id) => {
|
self.markers.forEach((marker, id) => {
|
||||||
const markerState = self.markerStates.get(String(id));
|
const markerState = self.markerStates.get(String(id));
|
||||||
// Only add most recent markers (those with icons) to OMS for spidering
|
|
||||||
// Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before
|
|
||||||
const shouldAddToOms =
|
const shouldAddToOms =
|
||||||
markerState?.is_most_recent_for_callsign === true ||
|
markerState?.is_most_recent_for_callsign === true ||
|
||||||
(markerState?.is_most_recent_for_callsign == null &&
|
(markerState?.is_most_recent_for_callsign == null &&
|
||||||
|
|
@ -1176,18 +1175,17 @@ let MapAPRSMap = {
|
||||||
const serverConvertKeys = payload.convert_to_historical;
|
const serverConvertKeys = payload.convert_to_historical;
|
||||||
let markersToConvert: string[] = [];
|
let markersToConvert: string[] = [];
|
||||||
|
|
||||||
// Build set of new packet IDs to avoid converting a marker we're about to add
|
// Single pass: build new IDs set and callsigns to convert
|
||||||
const allNewIds = new Set<string>();
|
const allNewIds = new Set<string>();
|
||||||
for (const data of packets) {
|
|
||||||
if (data.id) allNewIds.add(String(data.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which callsigns need conversion
|
|
||||||
const callsignsToConvert: Set<string> = new Set();
|
const callsignsToConvert: Set<string> = new Set();
|
||||||
if (serverConvertKeys && serverConvertKeys.length > 0) {
|
if (serverConvertKeys && serverConvertKeys.length > 0) {
|
||||||
for (const cs of serverConvertKeys) callsignsToConvert.add(cs);
|
for (const cs of serverConvertKeys) callsignsToConvert.add(cs);
|
||||||
|
for (const data of packets) {
|
||||||
|
if (data.id) allNewIds.add(String(data.id));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const data of packets) {
|
for (const data of packets) {
|
||||||
|
if (data.id) allNewIds.add(String(data.id));
|
||||||
const cs = data.callsign_group || data.callsign || data.id;
|
const cs = data.callsign_group || data.callsign || data.id;
|
||||||
if (cs) callsignsToConvert.add(cs);
|
if (cs) callsignsToConvert.add(cs);
|
||||||
}
|
}
|
||||||
|
|
@ -1909,22 +1907,12 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
// If marker._oms is true but no _omsData, let OMS handle it (will spiderfy)
|
// If marker._oms is true but no _omsData, let OMS handle it (will spiderfy)
|
||||||
|
|
||||||
// Bring the clicked marker to front
|
// Bring the clicked marker to front using a counter instead of DOM scan
|
||||||
if (marker.getElement) {
|
if (marker.getElement) {
|
||||||
const element = marker.getElement();
|
const element = marker.getElement();
|
||||||
if (element) {
|
if (element) {
|
||||||
// Find the highest z-index among all markers
|
self.markerZIndexCounter = (self.markerZIndexCounter || 1000) + 1;
|
||||||
let maxZIndex = 1000;
|
element.style.zIndex = self.markerZIndexCounter.toString();
|
||||||
document.querySelectorAll(".leaflet-marker-icon").forEach((el) => {
|
|
||||||
const zIndex = parseInt(
|
|
||||||
(el as HTMLElement).style.zIndex || "0",
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
if (zIndex > maxZIndex) maxZIndex = zIndex;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set this marker's z-index higher than all others
|
|
||||||
element.style.zIndex = (maxZIndex + 1).toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2380,19 +2368,22 @@ let MapAPRSMap = {
|
||||||
// If this is the most recent for the callsign, always show the APRS icon
|
// If this is the most recent for the callsign, always show the APRS icon
|
||||||
if (data.is_most_recent_for_callsign) {
|
if (data.is_most_recent_for_callsign) {
|
||||||
// Remove any historical dot at the same position for this callsign
|
// Remove any historical dot at the same position for this callsign
|
||||||
if (self.markers && self.markerStates) {
|
// Use callsignIndex for O(1) lookup instead of scanning all markers
|
||||||
self.markers.forEach((marker: APRSMarker, id: string) => {
|
if (self.markers && self.markerStates && self.callsignIndex && data.callsign_group) {
|
||||||
const state = self.markerStates!.get(String(id));
|
const ids = self.callsignIndex.get(data.callsign_group);
|
||||||
if (
|
if (ids) {
|
||||||
state &&
|
for (const id of ids) {
|
||||||
state.historical &&
|
const state = self.markerStates.get(String(id));
|
||||||
state.callsign_group === data.callsign_group &&
|
if (
|
||||||
Math.abs(state.lat - data.lat) < 0.00001 &&
|
state &&
|
||||||
Math.abs(state.lng - data.lng) < 0.00001
|
state.historical &&
|
||||||
) {
|
Math.abs(state.lat - data.lat) < 0.00001 &&
|
||||||
self.removeMarkerWithoutTrail(id);
|
Math.abs(state.lng - data.lng) < 0.00001
|
||||||
|
) {
|
||||||
|
self.removeMarkerWithoutTrail(String(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
// Use server-generated symbol HTML if available
|
// Use server-generated symbol HTML if available
|
||||||
if (data.symbol_html) {
|
if (data.symbol_html) {
|
||||||
|
|
@ -2482,6 +2473,11 @@ let MapAPRSMap = {
|
||||||
self.cleanupTimeouts = [];
|
self.cleanupTimeouts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up trail manager
|
||||||
|
if (self.trailManager) {
|
||||||
|
self.trailManager.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// Remove popup navigation event listener
|
// Remove popup navigation event listener
|
||||||
if (self.popupNavigationHandler) {
|
if (self.popupNavigationHandler) {
|
||||||
document.removeEventListener("click", self.popupNavigationHandler);
|
document.removeEventListener("click", self.popupNavigationHandler);
|
||||||
|
|
|
||||||
|
|
@ -88,15 +88,6 @@ export function saveMapState(map: L.Map, pushEvent: PushEventFunction) {
|
||||||
const truncatedLat = Math.round(center.lat * 100000) / 100000;
|
const truncatedLat = Math.round(center.lat * 100000) / 100000;
|
||||||
const truncatedLng = Math.round(center.lng * 100000) / 100000;
|
const truncatedLng = Math.round(center.lng * 100000) / 100000;
|
||||||
|
|
||||||
// Always save to localStorage, even if LiveView is disconnected
|
|
||||||
// Temporarily disabled - using server-side geolocation instead
|
|
||||||
/*
|
|
||||||
localStorage.setItem(
|
|
||||||
"aprs_map_state",
|
|
||||||
JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }),
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Send combined map state update to server for URL and bounds updating
|
// Send combined map state update to server for URL and bounds updating
|
||||||
const payload = {
|
const payload = {
|
||||||
center: { lat: truncatedLat, lng: truncatedLng },
|
center: { lat: truncatedLat, lng: truncatedLng },
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ defmodule Aprsme.CleanupScheduler do
|
||||||
def handle_info(:schedule_cleanup, %{interval: interval} = state) when is_integer(interval) do
|
def handle_info(:schedule_cleanup, %{interval: interval} = state) when is_integer(interval) do
|
||||||
Logger.info("Running packet cleanup task")
|
Logger.info("Running packet cleanup task")
|
||||||
|
|
||||||
# Run cleanup directly in a supervised task
|
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
|
||||||
Task.start(fn ->
|
|
||||||
try do
|
try do
|
||||||
PacketCleanupWorker.perform(%{})
|
PacketCleanupWorker.perform(%{})
|
||||||
rescue
|
rescue
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ defmodule Aprsme.Is.IsSupervisor do
|
||||||
{Aprsme.Is, []}
|
{Aprsme.Is, []}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Supervisor will restart children max 3 times in 5 seconds
|
# Allow more restarts over a longer window to survive transient network issues
|
||||||
# This prevents rapid reconnection attempts
|
Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 60)
|
||||||
Supervisor.init(children, strategy: :one_for_one, max_restarts: 3, max_seconds: 5)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -248,17 +248,6 @@ defmodule Aprsme.PacketConsumer do
|
||||||
},
|
},
|
||||||
%{}
|
%{}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Logger.info("Batch processing completed",
|
|
||||||
# batch_result:
|
|
||||||
# LogSanitizer.log_data(
|
|
||||||
# packet_count: length(packets),
|
|
||||||
# duration_ms: duration,
|
|
||||||
# success_count: success_count,
|
|
||||||
# error_count: error_count,
|
|
||||||
# memory_diff_bytes: memory_diff
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec process_chunk(list(map())) :: {non_neg_integer(), non_neg_integer()}
|
@spec process_chunk(list(map())) :: {non_neg_integer(), non_neg_integer()}
|
||||||
|
|
|
||||||
|
|
@ -227,11 +227,6 @@ defmodule Aprsme.Packets do
|
||||||
|
|
||||||
case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
|
case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
|
||||||
{:ok, packet} ->
|
{:ok, packet} ->
|
||||||
# Invalidate cache for this packet's callsign
|
|
||||||
if Map.has_key?(attrs, :sender) do
|
|
||||||
# Cache invalidation removed - no longer using CachedQueries
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, packet}
|
{:ok, packet}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|
@ -808,11 +803,16 @@ defmodule Aprsme.Packets do
|
||||||
"""
|
"""
|
||||||
@spec get_latest_weather_packet(String.t()) :: struct() | nil
|
@spec get_latest_weather_packet(String.t()) :: struct() | nil
|
||||||
def get_latest_weather_packet(callsign) when is_binary(callsign) do
|
def get_latest_weather_packet(callsign) when is_binary(callsign) do
|
||||||
# Use proper index with short time window first
|
import Ecto.Query
|
||||||
case get_latest_weather_in_window(callsign, 24) do
|
|
||||||
nil -> get_latest_weather_in_window(callsign, 168)
|
query =
|
||||||
packet -> packet
|
from p in Packet,
|
||||||
end
|
where: p.sender == ^callsign,
|
||||||
|
where: p.has_weather == true,
|
||||||
|
order_by: [desc: p.received_at],
|
||||||
|
limit: 1
|
||||||
|
|
||||||
|
Repo.one(query)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -879,22 +879,4 @@ defmodule Aprsme.Packets do
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_latest_weather_in_window(callsign, hours) do
|
|
||||||
import Ecto.Query
|
|
||||||
|
|
||||||
since = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
|
|
||||||
|
|
||||||
query =
|
|
||||||
from p in Packet,
|
|
||||||
where: p.sender == ^callsign,
|
|
||||||
where: p.received_at > ^since,
|
|
||||||
where:
|
|
||||||
not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.pressure) or
|
|
||||||
not is_nil(p.wind_speed) or not is_nil(p.wind_direction) or not is_nil(p.rain_1h),
|
|
||||||
order_by: [desc: p.received_at],
|
|
||||||
limit: 1
|
|
||||||
|
|
||||||
Repo.one(query)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ defmodule AprsmeWeb.Endpoint do
|
||||||
plug Plug.Static,
|
plug Plug.Static,
|
||||||
at: "/",
|
at: "/",
|
||||||
from: :aprsme,
|
from: :aprsme,
|
||||||
gzip: false,
|
gzip: true,
|
||||||
only: ~w(assets fonts images aprs-symbols favicon.ico robots.txt)
|
only: ~w(assets fonts images aprs-symbols favicon.ico robots.txt)
|
||||||
|
|
||||||
if Mix.env() == :dev do
|
if Mix.env() == :dev do
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ defmodule AprsmeWeb.Router do
|
||||||
plug :put_root_layout, {AprsmeWeb.Layouts, :root}
|
plug :put_root_layout, {AprsmeWeb.Layouts, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
|
|
||||||
# plug :put_secure_browser_headers, %{
|
plug :put_secure_browser_headers, %{
|
||||||
# "content-security-policy" =>
|
"content-security-policy" =>
|
||||||
# "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:"
|
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:"
|
||||||
# }
|
}
|
||||||
|
|
||||||
plug :fetch_current_user
|
plug :fetch_current_user
|
||||||
plug AprsmeWeb.Plugs.SetLocale
|
plug AprsmeWeb.Plugs.SetLocale
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue