diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx index e20ad77e..f0a019de 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx @@ -49,7 +49,7 @@ export const KillsCounter = ({ content={
- +
} diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx index a8499891..7fc083cb 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx @@ -48,7 +48,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps >
- {nodeVars.killsCount} + {localKillsCount}
)} diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeTheme.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeTheme.tsx index 6d39ebcf..b35a4d9d 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeTheme.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeTheme.tsx @@ -47,7 +47,7 @@ export const SolarSystemNodeTheme = memo((props: NodeProps) >
- {nodeVars.killsCount} + {localKillsCount}
)} diff --git a/assets/js/hooks/Mapper/components/map/hooks/useKillsCounter.ts b/assets/js/hooks/Mapper/components/map/hooks/useKillsCounter.ts index d9922a0e..8530528b 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/useKillsCounter.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/useKillsCounter.ts @@ -22,6 +22,7 @@ export function useKillsCounter({ realSystemId }: UseKillsCounterProps) { systemId: realSystemId, outCommand, showAllVisible: false, + sinceHours: 1, }); const filteredKills = useMemo(() => { diff --git a/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts b/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts index 9b4a1daa..0d5e9a9b 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts @@ -1,6 +1,7 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { useMapEventListener } from '@/hooks/Mapper/events'; import { Commands } from '@/hooks/Mapper/types'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; interface Kill { solar_system_id: number | string; @@ -9,29 +10,51 @@ interface Kill { interface MapEvent { name: Commands; - data?: any; + data?: unknown; payload?: Kill[]; } export function useNodeKillsCount(systemId: number | string, initialKillsCount: number | null): number | null { const [killsCount, setKillsCount] = useState(initialKillsCount); + const { data: mapData } = useMapRootState(); + const { detailedKills = {} } = mapData; + + // Calculate 1-hour kill count from detailed kills + const oneHourKillCount = useMemo(() => { + const systemKills = detailedKills[systemId] || []; + if (systemKills.length === 0) return null; + + const oneHourAgo = Date.now() - 60 * 60 * 1000; // 1 hour in milliseconds + const recentKills = systemKills.filter(kill => { + if (!kill.kill_time) return false; + const killTime = new Date(kill.kill_time).getTime(); + if (isNaN(killTime)) return false; + return killTime >= oneHourAgo; + }); + + return recentKills.length > 0 ? recentKills.length : null; + }, [detailedKills, systemId]); useEffect(() => { - setKillsCount(initialKillsCount); - }, [initialKillsCount]); + // Use 1-hour count if available, otherwise fall back to initial count + setKillsCount(oneHourKillCount !== null ? oneHourKillCount : initialKillsCount); + }, [oneHourKillCount, initialKillsCount]); const handleEvent = useCallback( (event: MapEvent): boolean => { if (event.name === Commands.killsUpdated && Array.isArray(event.payload)) { const killForSystem = event.payload.find(kill => kill.solar_system_id.toString() === systemId.toString()); if (killForSystem && typeof killForSystem.kills === 'number') { - setKillsCount(killForSystem.kills); + // Only update if we don't have detailed kills data + if (!detailedKills[systemId] || detailedKills[systemId].length === 0) { + setKillsCount(killForSystem.kills); + } } return true; } return false; }, - [systemId], + [systemId, detailedKills], ); useMapEventListener(handleEvent); diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/WSystemKills/hooks/useSystemKills.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/WSystemKills/hooks/useSystemKills.ts index 57addec2..c5a8eb8c 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/WSystemKills/hooks/useSystemKills.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/WSystemKills/hooks/useSystemKills.ts @@ -13,16 +13,13 @@ interface UseSystemKillsProps { sinceHours?: number; } -function combineKills(existing: DetailedKill[], incoming: DetailedKill[], sinceHours: number): DetailedKill[] { - const cutoff = Date.now() - sinceHours * 60 * 60 * 1000; +function combineKills(existing: DetailedKill[], incoming: DetailedKill[]): DetailedKill[] { + // Don't filter by time when storing - let components filter when displaying const byId: Record = {}; for (const kill of [...existing, ...incoming]) { if (!kill.kill_time) continue; - const killTimeMs = new Date(kill.kill_time).valueOf(); - if (killTimeMs >= cutoff) { - byId[kill.killmail_id] = kill; - } + byId[kill.killmail_id] = kill; } return Object.values(byId); @@ -55,14 +52,14 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s for (const [sid, newKills] of Object.entries(killsMap)) { const existing = updated[sid] ?? []; - const combined = combineKills(existing, newKills, effectiveSinceHours); + const combined = combineKills(existing, newKills); updated[sid] = combined; } return { ...prev, detailedKills: updated }; }); }, - [update, effectiveSinceHours], + [update], ); const fetchKills = useCallback( diff --git a/lib/wanderer_app/kills/client.ex b/lib/wanderer_app/kills/client.ex index 08f23e64..64b1bef6 100644 --- a/lib/wanderer_app/kills/client.ex +++ b/lib/wanderer_app/kills/client.ex @@ -16,11 +16,13 @@ defmodule WandererApp.Kills.Client do @retry_delays [5_000, 10_000, 30_000, 60_000] @max_retries 10 @health_check_interval :timer.seconds(30) # Check every 30 seconds + @message_timeout :timer.minutes(15) # No messages timeout defstruct [ :socket_pid, :retry_timer_ref, :connection_timeout_ref, + :last_message_time, connected: false, connecting: false, subscribed_systems: MapSet.new(), @@ -162,7 +164,8 @@ defmodule WandererApp.Kills.Client do connecting: false, socket_pid: socket_pid, retry_count: 0, # Reset retry count only on successful connection - last_error: nil + last_error: nil, + last_message_time: System.system_time(:millisecond) } |> cancel_retry() |> cancel_connection_timeout() @@ -255,16 +258,9 @@ defmodule WandererApp.Kills.Client do {:noreply, state} end - # Handle process DOWN messages for socket monitoring - def handle_info({:DOWN, _ref, :process, pid, reason}, %{socket_pid: pid} = state) do - Logger.error("[Client] Socket process died: #{inspect(reason)}") - send(self(), {:disconnected, {:socket_died, reason}}) - {:noreply, state} - end - - def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do - # Ignore DOWN messages for other processes - {:noreply, state} + def handle_info({:message_received, _type}, state) do + # Update last message time when we receive a kill message + {:noreply, %{state | last_message_time: System.system_time(:millisecond)}} end def handle_info(_msg, state), do: {:noreply, state} @@ -454,6 +450,22 @@ defmodule WandererApp.Kills.Client do :needs_reconnect end + defp check_health(%{socket_pid: pid, last_message_time: last_msg_time} = state) when not is_nil(last_msg_time) do + cond do + not socket_alive?(pid) -> + Logger.warning("[Client] Health check: Socket process #{inspect(pid)} is dead") + :needs_reconnect + + # Check if we haven't received a message in the configured timeout + System.system_time(:millisecond) - last_msg_time > @message_timeout -> + Logger.warning("[Client] Health check: No messages received for 15+ minutes, reconnecting") + :needs_reconnect + + true -> + :healthy + end + end + defp check_health(%{socket_pid: pid} = state) do if socket_alive?(pid) do :healthy @@ -565,6 +577,9 @@ defmodule WandererApp.Kills.Client do def handle_message(topic, event, payload, _transport, state) do case {topic, event} do {"killmails:lobby", "killmail_update"} -> + # Notify parent that we received a message + send(state.parent, {:message_received, :killmail_update}) + # Use supervised task to handle failures gracefully Task.Supervisor.start_child( WandererApp.Kills.TaskSupervisor, @@ -572,6 +587,9 @@ defmodule WandererApp.Kills.Client do ) {"killmails:lobby", "kill_count_update"} -> + # Notify parent that we received a message + send(state.parent, {:message_received, :kill_count_update}) + # Use supervised task to handle failures gracefully Task.Supervisor.start_child( WandererApp.Kills.TaskSupervisor, diff --git a/lib/wanderer_app/map/map_zkb_data_fetcher.ex b/lib/wanderer_app/map/map_zkb_data_fetcher.ex index 9878780c..b9cef28f 100644 --- a/lib/wanderer_app/map/map_zkb_data_fetcher.ex +++ b/lib/wanderer_app/map/map_zkb_data_fetcher.ex @@ -7,6 +7,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do require Logger + alias WandererApp.Map.Server.Impl, as: MapServerImpl @interval :timer.seconds(15) @store_map_kills_timeout :timer.hours(1) @@ -109,56 +110,32 @@ defmodule WandererApp.Map.ZkbDataFetcher do {solar_system_id, MapSet.new(ids)} end) - # Find systems with changed killmail lists + # Find systems with changed killmail lists or empty detailed kills changed_systems = new_ids_map |> Enum.filter(fn {system_id, new_ids_set} -> old_set = MapSet.new(Map.get(old_ids_map, system_id, [])) - not MapSet.equal?(new_ids_set, old_set) + old_details = Map.get(old_details_map, system_id, []) + # Update if IDs changed OR if we have IDs but no detailed kills + not MapSet.equal?(new_ids_set, old_set) or + (MapSet.size(new_ids_set) > 0 and old_details == []) end) |> Enum.map(&elem(&1, 0)) if changed_systems == [] do - Logger.debug(fn -> - "[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}" - end) + log_no_changes(map_id) # Don't overwrite existing cache data when there are no changes # Only initialize if cache doesn't exist - if old_details_map == %{} do - # First time initialization - create empty structure - empty_map = systems - |> Enum.into(%{}, fn {system_id, _} -> {system_id, []} end) - - WandererApp.Cache.insert(cache_key_details, empty_map, ttl: :timer.hours(@killmail_ttl_hours)) - end + maybe_initialize_empty_details_map(old_details_map, systems, cache_key_details) :ok else # Build new details for each changed system - updated_details_map = - Enum.reduce(changed_systems, old_details_map, fn system_id, acc -> - kill_ids = - new_ids_map - |> Map.fetch!(system_id) - |> MapSet.to_list() - - # Get killmail details from cache (populated by WebSocket) - kill_details = - kill_ids - |> Enum.map(&WandererApp.Cache.get("zkb:killmail:#{&1}")) - |> Enum.reject(&is_nil/1) - - # Ensure system_id is an integer key - Map.put(acc, system_id, kill_details) - end) + updated_details_map = build_updated_details_map(changed_systems, old_details_map, new_ids_map) # Update the ID map cache - updated_ids_map = - Enum.reduce(changed_systems, old_ids_map, fn system_id, acc -> - new_ids_list = new_ids_map[system_id] |> MapSet.to_list() - Map.put(acc, system_id, new_ids_list) - end) + updated_ids_map = build_updated_ids_map(changed_systems, old_ids_map, new_ids_map) # Store updated caches WandererApp.Cache.insert(cache_key_ids, updated_ids_map, @@ -171,7 +148,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do # Broadcast changes changed_data = Map.take(updated_details_map, changed_systems) - WandererApp.Map.Server.Impl.broadcast!(map_id, :detailed_kills_updated, changed_data) + MapServerImpl.broadcast!(map_id, :detailed_kills_updated, changed_data) :ok end @@ -203,7 +180,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do payload = Map.take(new_kills_map, changed_system_ids) - WandererApp.Map.Server.Impl.broadcast!(map_id, :kills_updated, payload) + MapServerImpl.broadcast!(map_id, :kills_updated, payload) :ok end @@ -217,4 +194,40 @@ defmodule WandererApp.Map.ZkbDataFetcher do :ok end end + + defp maybe_initialize_empty_details_map(%{}, systems, cache_key_details) do + # First time initialization - create empty structure + initial_map = Enum.into(systems, %{}, fn {system_id, _} -> {system_id, []} end) + WandererApp.Cache.insert(cache_key_details, initial_map, ttl: :timer.hours(@killmail_ttl_hours)) + end + + defp maybe_initialize_empty_details_map(_old_details_map, _systems, _cache_key_details), do: :ok + + defp build_updated_details_map(changed_systems, old_details_map, new_ids_map) do + Enum.reduce(changed_systems, old_details_map, fn system_id, acc -> + kill_details = get_kill_details_for_system(system_id, new_ids_map) + Map.put(acc, system_id, kill_details) + end) + end + + defp get_kill_details_for_system(system_id, new_ids_map) do + new_ids_map + |> Map.fetch!(system_id) + |> MapSet.to_list() + |> Enum.map(&WandererApp.Cache.get("zkb:killmail:#{&1}")) + |> Enum.reject(&is_nil/1) + end + + defp build_updated_ids_map(changed_systems, old_ids_map, new_ids_map) do + Enum.reduce(changed_systems, old_ids_map, fn system_id, acc -> + new_ids_list = new_ids_map[system_id] |> MapSet.to_list() + Map.put(acc, system_id, new_ids_list) + end) + end + + defp log_no_changes(map_id) do + Logger.debug(fn -> + "[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}" + end) + end end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex index eac4888f..e17d98e3 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex @@ -7,48 +7,24 @@ defmodule WandererAppWeb.MapKillsEventHandler do use WandererAppWeb, :live_component require Logger - alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} + alias WandererAppWeb.{MapCoreEventHandler, MapEventHandler} def handle_server_event( %{event: :init_kills}, %{assigns: %{map_id: map_id} = assigns} = socket ) do - + # Get kill counts from cache case WandererApp.Map.get_map(map_id) do {:ok, %{systems: systems}} -> - - kill_counts = - systems - |> Enum.into(%{}, fn {solar_system_id, _system} -> - # Use explicit cache lookup with validation from WandererApp.Cache - kills_count = - case WandererApp.Cache.get("zkb:kills:#{solar_system_id}") do - count when is_integer(count) and count >= 0 -> - count - nil -> - 0 - - invalid_data -> - Logger.warning( - "[#{__MODULE__}] Invalid kill count data for system #{solar_system_id}: #{inspect(invalid_data)}" - ) - - 0 - end - - {solar_system_id, kills_count} - end) - |> Enum.filter(fn {_system_id, count} -> count > 0 end) - |> Enum.into(%{}) + kill_counts = build_kill_counts(systems) kills_payload = kill_counts |> Enum.map(fn {system_id, kills} -> %{solar_system_id: system_id, kills: kills} end) - - + MapEventHandler.push_map_event( socket, "kills_updated", @@ -62,7 +38,7 @@ defmodule WandererAppWeb.MapKillsEventHandler do end def handle_server_event(%{event: :update_system_kills, payload: solar_system_id}, socket) do - + # Get kill count for the specific system kills_count = case WandererApp.Cache.get("zkb:kills:#{solar_system_id}") do count when is_integer(count) and count >= 0 -> @@ -73,7 +49,7 @@ defmodule WandererAppWeb.MapKillsEventHandler do Logger.warning("[#{__MODULE__}] Invalid kill count data for new system #{solar_system_id}: #{inspect(invalid_data)}") 0 end - + # Only send update if there are kills if kills_count > 0 do MapEventHandler.push_map_event(socket, "kills_updated", [%{solar_system_id: solar_system_id, kills: kills_count}]) @@ -169,6 +145,7 @@ defmodule WandererAppWeb.MapKillsEventHandler do defp handle_get_system_kills(sid, sh, payload, socket) do with {:ok, system_id} <- parse_id(sid), + # Parse since_hours for validation, but filtering is done on frontend {:ok, _since_hours} <- parse_id(sh) do cache_key = "map:#{socket.assigns.map_id}:zkb:detailed_kills" @@ -210,43 +187,20 @@ defmodule WandererAppWeb.MapKillsEventHandler do end defp handle_get_systems_kills(sids, sh, payload, socket) do + # Parse since_hours for validation, but filtering is done on frontend with {:ok, _since_hours} <- parse_id(sh), {:ok, parsed_ids} <- parse_system_ids(sids) do cache_key = "map:#{socket.assigns.map_id}:zkb:detailed_kills" # Get from WandererApp.Cache (not Cachex) - filtered_data = - case WandererApp.Cache.get(cache_key) do - cached_map when is_map(cached_map) -> - # Validate and filter cached data - parsed_ids - |> Enum.reduce(%{}, fn system_id, acc -> - case Map.get(cached_map, system_id) do - kills when is_list(kills) -> Map.put(acc, system_id, kills) - _ -> acc - end - end) - - nil -> - %{} - - invalid_data -> - Logger.warning( - "[#{__MODULE__}] Invalid cache data structure for key: #{cache_key}, got: #{inspect(invalid_data)}" - ) - - # Clear invalid cache entry - WandererApp.Cache.delete(cache_key) - %{} - end + filtered_data = get_kills_for_systems(cache_key, parsed_ids) # filtered_data is already the final result, not wrapped in a tuple systems_data = filtered_data reply_payload = %{"systems_kills" => systems_data} - {:reply, reply_payload, socket} else :error -> @@ -281,4 +235,62 @@ defmodule WandererAppWeb.MapKillsEventHandler do end defp parse_system_ids(_), do: :error + + defp build_kill_counts(systems) do + systems + |> Enum.map(&extract_system_kill_count/1) + |> Enum.filter(fn {_system_id, count} -> count > 0 end) + |> Enum.into(%{}) + end + + defp extract_system_kill_count({solar_system_id, _system}) do + kills_count = get_validated_kill_count(solar_system_id) + {solar_system_id, kills_count} + end + + defp get_validated_kill_count(solar_system_id) do + case WandererApp.Cache.get("zkb:kills:#{solar_system_id}") do + count when is_integer(count) and count >= 0 -> + count + + nil -> + 0 + + invalid_data -> + Logger.warning( + "[#{__MODULE__}] Invalid kill count data for system #{solar_system_id}: #{inspect(invalid_data)}" + ) + 0 + end + end + + defp get_kills_for_systems(cache_key, system_ids) do + case WandererApp.Cache.get(cache_key) do + cached_map when is_map(cached_map) -> + extract_cached_kills(cached_map, system_ids) + + nil -> + %{} + + invalid_data -> + Logger.warning( + "[#{__MODULE__}] Invalid cache data structure for key: #{cache_key}, got: #{inspect(invalid_data)}" + ) + # Clear invalid cache entry + WandererApp.Cache.delete(cache_key) + %{} + end + end + + defp extract_cached_kills(cached_map, system_ids) do + Enum.reduce(system_ids, %{}, fn system_id, acc -> + case Map.get(cached_map, system_id) do + kills when is_list(kills) -> + Map.put(acc, system_id, kills) + _ -> + acc + end + end) + end + end