diff --git a/.env.example b/.env.example index 9f90a139..8f4ecd8f 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,4 @@ export EVE_CLIENT_WITH_WALLET_SECRET="" export GIT_SHA="1111" export WANDERER_INVITES="false" export WANDERER_PUBLIC_API_DISABLED="false" +export WANDERER_ZKILL_PRELOAD_DISABLED="false" diff --git a/assets/js/hooks/Mapper/components/map/MapProvider.tsx b/assets/js/hooks/Mapper/components/map/MapProvider.tsx index c0960564..e31d9587 100644 --- a/assets/js/hooks/Mapper/components/map/MapProvider.tsx +++ b/assets/js/hooks/Mapper/components/map/MapProvider.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext } from 'react'; import { OutCommandHandler } from '@/hooks/Mapper/types/mapHandlers.ts'; -import { MapUnionTypes } from '@/hooks/Mapper/types'; +import { MapUnionTypes, SystemSignature } from '@/hooks/Mapper/types'; import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils'; export type MapData = MapUnionTypes & { @@ -30,10 +30,13 @@ const INITIAL_DATA: MapData = { isConnecting: false, connections: [], hoverNodeId: null, + linkedSigEveId: '', visibleNodes: new Set(), showKSpaceBG: false, isThickConnections: false, userPermissions: {}, + systemSignatures: {} as Record, + options: {} as Record, }; export interface MapContextProps { diff --git a/assets/js/hooks/Mapper/components/mapInterface/constants.tsx b/assets/js/hooks/Mapper/components/mapInterface/constants.tsx index ac4e893b..75c587e1 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/constants.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/constants.tsx @@ -5,6 +5,7 @@ import { SystemInfo, SystemSignatures, SystemStructures, + SystemKills, } from '@/hooks/Mapper/components/mapInterface/widgets'; export const CURRENT_WINDOWS_VERSION = 8; @@ -16,6 +17,7 @@ export enum WidgetsIds { local = 'local', routes = 'routes', structures = 'structures', + kills = 'kills', } export const STORED_VISIBLE_WIDGETS_DEFAULT = [ @@ -61,6 +63,13 @@ export const DEFAULT_WIDGETS: WindowProps[] = [ zIndex: 0, content: () => , }, + { + id: WidgetsIds.kills, + position: { x: 270, y: 730 }, + size: { width: 510, height: 200 }, + zIndex: 0, + content: () => , + }, ]; type WidgetsCheckboxesType = { @@ -89,4 +98,18 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [ id: WidgetsIds.structures, label: 'Structures', }, + { + id: WidgetsIds.kills, + label: 'Kills', + }, ]; + +export function getWidgetsCheckboxesProps(detailedKillsDisabled: boolean): WidgetsCheckboxesType { + return filterOutKills(WIDGETS_CHECKBOXES_PROPS, detailedKillsDisabled); +} + + +function filterOutKills(items: T[], shouldFilter: boolean) { + if (!shouldFilter) return items; + return items.filter((w) => w.id !== WidgetsIds.kills); +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKills.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKills.tsx new file mode 100644 index 00000000..808e006e --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKills.tsx @@ -0,0 +1,85 @@ +import React, { useMemo, useState } from 'react'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { Widget } from '@/hooks/Mapper/components/mapInterface/components'; +import { SystemKillsContent } from './SystemKillsContent/SystemKillsContent'; +import { KillsHeader } from './components/SystemKillsHeader'; +import { useKillsWidgetSettings } from './hooks/useKillsWidgetSettings'; +import { useSystemKills } from './hooks/useSystemKills'; +import { KillsSettingsDialog } from './components/SystemKillsSettingsDialog'; + +export const SystemKills: React.FC = () => { + const { + data: { selectedSystems, systems }, + outCommand, + } = useMapRootState(); + + const [systemId] = selectedSystems || []; + + const [settingsDialogVisible, setSettingsDialogVisible] = useState(false); + + const systemNameMap = useMemo(() => { + const map: Record = {}; + systems.forEach(sys => { + map[sys.id] = sys.temporary_name || sys.name || '???'; + }); + return map; + }, [systems]); + + const [settings] = useKillsWidgetSettings(); + const visible = settings.showAll; + + const { kills, isLoading, error } = useSystemKills({ + systemId, + outCommand, + showAllVisible: visible, + }); + + const isNothingSelected = !systemId && !visible; + const showLoading = isLoading && kills.length === 0; + + return ( +
+
+ setSettingsDialogVisible(true)} />}> + {isNothingSelected && ( +
+ No system selected (or toggle “Show all systems”) +
+ )} + + {!isNothingSelected && showLoading && ( +
+ Loading Kills... +
+ )} + + {!isNothingSelected && !showLoading && error && ( +
+ {error} +
+ )} + + {!isNothingSelected && !showLoading && !error && (!kills || kills.length === 0) && ( +
+ No kills found +
+ )} + + {!isNothingSelected && !showLoading && !error && ( +
+ +
+ )} +
+
+ + +
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent.module.scss b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent.module.scss new file mode 100644 index 00000000..8f882e96 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent.module.scss @@ -0,0 +1,16 @@ +.TableRowCompact { + height: 8px; + max-height: 8px; + font-size: 12px !important; + line-height: 8px; +} + +.Table { + font-size: 12px; + border-collapse: collapse; +} + +.Tooltip { + white-space: pre-line; + line-height: 1.2rem; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent.tsx new file mode 100644 index 00000000..65a79baf --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent.tsx @@ -0,0 +1,50 @@ +import React, { useMemo } from 'react'; +import clsx from 'clsx'; +import { DetailedKill } from '@/hooks/Mapper/types/kills'; +import { KillRow } from '../components/SystemKillsRow'; + +interface SystemKillsContentProps { + kills: DetailedKill[]; + systemNameMap: Record; + compact?: boolean; + onlyOneSystem?: boolean; +} + +export const SystemKillsContent: React.FC = ({ + kills, + systemNameMap, + compact = false, + onlyOneSystem = false, +}) => { + const sortedKills = useMemo(() => { + return [...kills].sort((a, b) => { + const timeA = a.kill_time ? new Date(a.kill_time).getTime() : 0; + const timeB = b.kill_time ? new Date(b.kill_time).getTime() : 0; + return timeB - timeA; + }); + }, [kills]); + + return ( +
+ {sortedKills.map(kill => { + const systemIdStr = String(kill.solar_system_id); + const systemName = systemNameMap[systemIdStr] || `System ${systemIdStr}`; + + return ( + + ); + })} +
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/CompactKillRow.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/CompactKillRow.tsx new file mode 100644 index 00000000..9a4392e5 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/CompactKillRow.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import clsx from 'clsx'; +import { DetailedKill } from '@/hooks/Mapper/types/kills'; +import { + formatISK, + formatTimeMixed, + zkillLink, + getAttackerSubscript, + buildVictimImageUrls, + buildAttackerShipUrl, +} from '../helpers'; +import classes from './SystemKillRow.module.scss'; + +export interface CompactKillRowProps { + killDetails: DetailedKill; + systemName: string; + onlyOneSystem: boolean; +} + +export const CompactKillRow: React.FC = ({ killDetails, systemName, onlyOneSystem }) => { + const { + killmail_id, + victim_char_name = 'Unknown Pilot', + victim_ship_name = 'Unknown Ship', + victim_alliance_ticker, + victim_corp_ticker, + victim_char_id, + victim_corp_id, + victim_alliance_id, + victim_ship_type_id, + + final_blow_char_id, + final_blow_char_name = '', + final_blow_alliance_ticker, + final_blow_corp_ticker, + final_blow_ship_type_id, + + kill_time, + total_value, + } = killDetails; + + const attackerIsNpc = final_blow_char_id == null; + + const victimAffiliationTicker = victim_alliance_ticker || victim_corp_ticker || 'No Ticker'; + const killValueFormatted = total_value && total_value > 0 ? `${formatISK(total_value)} ISK` : null; + + const attackerName = attackerIsNpc ? '' : final_blow_char_name; + const attackerTicker = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || ''; + + const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago'; + const attackerSubscript = getAttackerSubscript(killDetails); + + const { victimShipUrl } = buildVictimImageUrls({ + victim_char_id, + victim_ship_type_id, + victim_corp_id, + victim_alliance_id, + }); + const finalBlowShipUrl = buildAttackerShipUrl(final_blow_ship_type_id); + + return ( +
+ {victimShipUrl && ( + + VictimShip + + )} + +
+
+ {victim_char_name} + / {victimAffiliationTicker} +
+
+ {victim_ship_name} + {killValueFormatted && ( + <> + / + {killValueFormatted} + + )} +
+
+ +
+
+ {!attackerIsNpc && (attackerName || attackerTicker) && ( +
+ {attackerName} + {attackerTicker && / {attackerTicker}} +
+ )} +
+ {!onlyOneSystem && systemName ? ( + <> + {systemName} /{killTimeAgo} + + ) : ( + {killTimeAgo} + )} +
+
+ + {finalBlowShipUrl && ( + + AttackerShip + {attackerSubscript && ( + + {attackerSubscript.label} + + )} + + )} +
+
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/FullKillRow.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/FullKillRow.tsx new file mode 100644 index 00000000..bee516c4 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/FullKillRow.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import clsx from 'clsx'; +import { DetailedKill } from '@/hooks/Mapper/types/kills'; +import { KillRowSubInfo } from './KillRowSubInfo'; +import { + formatISK, + formatTimeMixed, + zkillLink, + getAttackerSubscript, + buildVictimImageUrls, + buildAttackerShipUrl, +} from '../helpers'; +import classes from './SystemKillRow.module.scss'; + +export interface FullKillRowProps { + killDetails: DetailedKill; + systemName: string; + onlyOneSystem: boolean; +} + +export const FullKillRow: React.FC = ({ killDetails, systemName, onlyOneSystem }) => { + const { + killmail_id, + victim_char_name = '', + victim_alliance_ticker, + victim_corp_ticker, + victim_ship_name = '', + victim_char_id, + victim_corp_id, + victim_alliance_id, + victim_ship_type_id, + + total_value, + kill_time, + + final_blow_char_id, + final_blow_char_name = '', + final_blow_alliance_ticker, + final_blow_corp_ticker, + final_blow_ship_name = '', + final_blow_ship_type_id, + } = killDetails; + + const attackerIsNpc = final_blow_char_id == null; + + const victimAffiliation = victim_alliance_ticker || victim_corp_ticker || ''; + const attackerAffiliation = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || ''; + + const killValueFormatted = total_value && total_value > 0 ? `${formatISK(total_value)} ISK` : null; + const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago'; + + const { victimPortraitUrl, victimCorpLogoUrl, victimAllianceLogoUrl, victimShipUrl } = buildVictimImageUrls({ + victim_char_id, + victim_ship_type_id, + victim_corp_id, + victim_alliance_id, + }); + + const finalBlowShipUrl = buildAttackerShipUrl(final_blow_ship_type_id); + + const attackerSubscript = getAttackerSubscript(killDetails); + + return ( +
+
+ {victimShipUrl && ( +
+ + VictimShip + +
+ )} + +
+ +
+ +
+
+ {victim_char_name} + {victimAffiliation && / {victimAffiliation}} +
+
+ {victim_ship_name} + {killValueFormatted && ( + <> + / + {killValueFormatted} + + )} +
+
{!onlyOneSystem && systemName && {systemName}}
+
+
+ +
+
+ {!attackerIsNpc && ( +
+ {final_blow_char_name} + {attackerAffiliation && / {attackerAffiliation}} +
+ )} + {!attackerIsNpc && final_blow_ship_name && ( +
{final_blow_ship_name}
+ )} +
{killTimeAgo}
+
+ {finalBlowShipUrl && ( + + )} +
+
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/KillRowSubInfo.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/KillRowSubInfo.tsx new file mode 100644 index 00000000..e784c73d --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/KillRowSubInfo.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import clsx from 'clsx'; +import { zkillLink } from '../helpers'; +import classes from './SystemKillRow.module.scss'; + +interface KillRowSubInfoProps { + victimCorpId: number | null | undefined; + victimCorpLogoUrl: string | null; + victimAllianceId: number | null | undefined; + victimAllianceLogoUrl: string | null; + victimCharacterId: number | null | undefined; + victimPortraitUrl: string | null; +} + +export const KillRowSubInfo: React.FC = ({ + victimCorpId, + victimCorpLogoUrl, + victimAllianceId, + victimAllianceLogoUrl, + victimCharacterId, + victimPortraitUrl, +}) => { + const hasAnything = victimPortraitUrl || victimCorpLogoUrl || victimAllianceLogoUrl; + + if (!hasAnything) { + return null; + } + + return ( +
+ {victimPortraitUrl && victimCharacterId && ( + + VictimPortrait + + )} +
+ {victimCorpLogoUrl && victimCorpId && ( + + VictimCorp + + )} + {victimAllianceLogoUrl && victimAllianceId && ( + + VictimAlliance + + )} +
+
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillRow.module.scss b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillRow.module.scss new file mode 100644 index 00000000..dd46c3ac --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillRow.module.scss @@ -0,0 +1,26 @@ +.killRowContainer { + @apply flex items-center border-b border-stone-700 whitespace-nowrap overflow-hidden; +} + +.killRowImage { + @apply border border-stone-800 rounded-[4px] object-contain; +} + +.attackerCountLabel { + position: absolute; + bottom: 0; + right: 0; + font-size: 10px; + padding: 0 2px; +} + +.attackerCountLabelCompact { + position: absolute; + left: 0; + bottom: 0; + font-size: 0.6rem; + line-height: 1; + background-color: rgba(0, 0, 0, 0.7); + padding: 1px 2px; + pointer-events: none; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsHeader.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsHeader.tsx new file mode 100644 index 00000000..b8bed7d0 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsHeader.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { + LayoutEventBlocker, + WdCheckbox, + WdImgButton, + TooltipPosition, + SystemView, +} from '@/hooks/Mapper/components/ui-kit'; +import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings'; +import { PrimeIcons } from 'primereact/api'; + +interface KillsWidgetHeaderProps { + systemId?: string; + onOpenSettings: () => void; +} + +export const KillsHeader: React.FC = ({ systemId, onOpenSettings }) => { + const [settings, setSettings] = useKillsWidgetSettings(); + const { showAll } = settings; + + const onToggleShowAllVisible = () => { + setSettings(prev => ({ ...prev, showAll: !prev.showAll })); + }; + + return ( +
+
+
+ Kills + {systemId && !showAll && ' in '} +
+ {systemId && !showAll && } +
+ + + + + + +
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsRow.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsRow.tsx new file mode 100644 index 00000000..8217adbd --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsRow.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { DetailedKill } from '@/hooks/Mapper/types/kills'; +import { CompactKillRow } from './CompactKillRow'; +import { FullKillRow } from './FullKillRow'; + +export interface KillRowProps { + killDetails: DetailedKill; + systemName: string; + isCompact?: boolean; + onlyOneSystem?: boolean; +} + +export const KillRow: React.FC = ({ + killDetails, + systemName, + isCompact = false, + onlyOneSystem = false, +}) => { + if (isCompact) { + return ; + } + + return ; +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsSettingsDialog.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsSettingsDialog.tsx new file mode 100644 index 00000000..71bc7f6b --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/components/SystemKillsSettingsDialog.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Dialog } from 'primereact/dialog'; +import { Button } from 'primereact/button'; +import { WdImgButton, SystemView, TooltipPosition } from '@/hooks/Mapper/components/ui-kit'; +import { PrimeIcons } from 'primereact/api'; +import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings'; +import { + AddSystemDialog, + SearchOnSubmitCallback, +} from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog'; + +interface KillsSettingsDialogProps { + visible: boolean; + setVisible: (visible: boolean) => void; +} + +export const KillsSettingsDialog: React.FC = ({ visible, setVisible }) => { + const [globalSettings, setGlobalSettings] = useKillsWidgetSettings(); + const localRef = useRef({ + compact: globalSettings.compact, + showAll: globalSettings.showAll, + excludedSystems: globalSettings.excludedSystems || [], + }); + + const [, forceRender] = useState(0); + + const [addSystemDialogVisible, setAddSystemDialogVisible] = useState(false); + + useEffect(() => { + if (visible) { + localRef.current = { + compact: globalSettings.compact, + showAll: globalSettings.showAll, + excludedSystems: globalSettings.excludedSystems || [], + }; + forceRender(n => n + 1); + } + }, [visible, globalSettings]); + + const handleCompactChange = useCallback((checked: boolean) => { + localRef.current = { + ...localRef.current, + compact: checked, + }; + forceRender(n => n + 1); + }, []); + + const handleRemoveSystem = useCallback((sysId: number) => { + localRef.current = { + ...localRef.current, + excludedSystems: localRef.current.excludedSystems.filter(id => id !== sysId), + }; + forceRender(n => n + 1); + }, []); + + const handleAddSystemSubmit: SearchOnSubmitCallback = useCallback(item => { + + if (localRef.current.excludedSystems.includes(item.value)) { + return; + } + localRef.current = { + ...localRef.current, + excludedSystems: [...localRef.current.excludedSystems, item.value], + }; + forceRender(n => n + 1); + }, []); + + const handleApply = useCallback(() => { + setGlobalSettings(prev => ({ + ...prev, + ...localRef.current, + })); + setVisible(false); + }, [setGlobalSettings, setVisible]); + + const handleHide = useCallback(() => { + setVisible(false); + }, [setVisible]); + + const localData = localRef.current; + const excluded = localData.excludedSystems || []; + + return ( + +
+
+ handleCompactChange(e.target.checked)} + /> + +
+ +
+
+ + setAddSystemDialogVisible(true)} + tooltip={{ content: 'Add system to excluded list' }} + /> +
+ {excluded.length === 0 &&
No systems excluded.
} + {excluded.map(sysId => ( +
+ + + handleRemoveSystem(sysId)} + tooltip={{ content: 'Remove from excluded', position: TooltipPosition.top }} + /> +
+ ))} +
+ + {/* Apply + Close button row */} +
+
+
+ + {/* AddSystemDialog for picking new systems to exclude */} + setAddSystemDialogVisible(false)} + onSubmit={handleAddSystemSubmit} + excludedSystems={excluded} + /> +
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/index.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/index.ts new file mode 100644 index 00000000..c876517e --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './linkHelpers'; +export * from './killRowUtils'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/killRowUtils.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/killRowUtils.ts new file mode 100644 index 00000000..8cd4746d --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/killRowUtils.ts @@ -0,0 +1,51 @@ +import { DetailedKill } from '@/hooks/Mapper/types/kills'; + +/** Returns "5m ago", "3h ago", "2.5d ago", etc. */ +export function formatTimeMixed(killTime: string): string { + const killDate = new Date(killTime); + const diffMs = Date.now() - killDate.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + + if (diffHours < 1) { + const mins = Math.round(diffHours * 60); + return `${mins}m ago`; + } else if (diffHours < 24) { + const hours = Math.round(diffHours); + return `${hours}h ago`; + } else { + const days = diffHours / 24; + const roundedDays = days.toFixed(1); + return `${roundedDays}d ago`; + } +} + +/** Formats integer ISK values into k/M/B/T. */ +export function formatISK(value: number): string { + if (value >= 1_000_000_000_000) { + return `${(value / 1_000_000_000_000).toFixed(2)}T`; + } else if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(2)}B`; + } else if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M`; + } else if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}k`; + } + return Math.round(value).toString(); +} + +/** + * Determines whether this was an NPC kill, solo kill, etc. + * Returns { label: string, cssClass: string } for display, or null if none. + */ +export function getAttackerSubscript(kill: DetailedKill) { + if (kill.npc) { + return { label: 'npc', cssClass: 'text-purple-400' }; + } + const count = kill.attacker_count ?? 0; + if (count === 1) { + return { label: 'solo', cssClass: 'text-green-400' }; + } else if (count > 1) { + return { label: String(count), cssClass: 'text-white' }; + } + return null; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/linkHelpers.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/linkHelpers.ts new file mode 100644 index 00000000..20d6c0f7 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/helpers/linkHelpers.ts @@ -0,0 +1,52 @@ +const ZKILL_URL = 'https://zkillboard.com'; +const BASE_IMAGE_URL = 'https://images.evetech.net'; + +export function zkillLink(type: 'kill' | 'character' | 'corporation' | 'alliance', id?: number | null): string { + if (!id) return `${ZKILL_URL}`; + if (type === 'kill') return `${ZKILL_URL}/kill/${id}/`; + if (type === 'character') return `${ZKILL_URL}/character/${id}/`; + if (type === 'corporation') return `${ZKILL_URL}/corporation/${id}/`; + if (type === 'alliance') return `${ZKILL_URL}/alliance/${id}/`; + return `${ZKILL_URL}`; +} + +export function eveImageUrl( + category: 'characters' | 'corporations' | 'alliances' | 'types', + id?: number | null, + variation: string = 'icon', + size?: number, +): string | null { + if (!id || id <= 0) { + return null; + } + let url = `${BASE_IMAGE_URL}/${category}/${id}/${variation}`; + if (size) { + url += `?size=${size}`; + } + return url; +} + +export function buildVictimImageUrls(args: { + victim_char_id?: number | null; + victim_ship_type_id?: number | null; + victim_corp_id?: number | null; + victim_alliance_id?: number | null; +}) { + const { victim_char_id, victim_ship_type_id, victim_corp_id, victim_alliance_id } = args; + + const victimPortraitUrl = eveImageUrl('characters', victim_char_id, 'portrait', 64); + const victimShipUrl = eveImageUrl('types', victim_ship_type_id, 'render', 64); + const victimCorpLogoUrl = eveImageUrl('corporations', victim_corp_id, 'logo', 32); + const victimAllianceLogoUrl = eveImageUrl('alliances', victim_alliance_id, 'logo', 32); + + return { + victimPortraitUrl, + victimShipUrl, + victimCorpLogoUrl, + victimAllianceLogoUrl, + }; +} + +export function buildAttackerShipUrl(final_blow_ship_type_id?: number | null): string | null { + return eveImageUrl('types', final_blow_ship_type_id, 'render', 64); +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/hooks/useKillsWidgetSettings.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/hooks/useKillsWidgetSettings.ts new file mode 100644 index 00000000..dfaaec4e --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/hooks/useKillsWidgetSettings.ts @@ -0,0 +1,51 @@ +import { useMemo, useCallback } from 'react'; +import useLocalStorageState from 'use-local-storage-state'; + +export interface KillsWidgetSettings { + compact: boolean; + showAll: boolean; + excludedSystems: number[]; + version: number; +} + +export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = { + compact: false, + showAll: false, + excludedSystems: [], + version: 0, +}; + +function mergeWithDefaults(settings?: Partial): KillsWidgetSettings { + if (!settings) { + return DEFAULT_KILLS_WIDGET_SETTINGS; + } + + return { + ...DEFAULT_KILLS_WIDGET_SETTINGS, + ...settings, + excludedSystems: Array.isArray(settings.excludedSystems) ? settings.excludedSystems : [], + }; +} + +export function useKillsWidgetSettings() { + const [rawValue, setRawValue] = useLocalStorageState('kills:widget:settings'); + + const value = useMemo(() => { + return mergeWithDefaults(rawValue); + }, [rawValue]); + + const setValue = useCallback( + (newVal: KillsWidgetSettings | ((prev: KillsWidgetSettings) => KillsWidgetSettings)) => { + setRawValue(prev => { + const mergedPrev = mergeWithDefaults(prev); + + const nextUnmerged = typeof newVal === 'function' ? newVal(mergedPrev) : newVal; + + return mergeWithDefaults(nextUnmerged); + }); + }, + [setRawValue], + ); + + return [value, setValue] as const; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/hooks/useSystemKills.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/hooks/useSystemKills.ts new file mode 100644 index 00000000..67e82cda --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/hooks/useSystemKills.ts @@ -0,0 +1,178 @@ +import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; +import debounce from 'lodash.debounce'; +import { OutCommand } from '@/hooks/Mapper/types/mapHandlers'; +import { DetailedKill } from '@/hooks/Mapper/types/kills'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { useKillsWidgetSettings } from './useKillsWidgetSettings'; + +interface UseSystemKillsProps { + systemId?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outCommand: (payload: any) => Promise; + showAllVisible?: boolean; + sinceHours?: number; +} + +function combineKills(existing: DetailedKill[], incoming: DetailedKill[], sinceHours: number): DetailedKill[] { + const cutoff = Date.now() - sinceHours * 60 * 60 * 1000; + 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; + } + } + + return Object.values(byId); +} + +export function useSystemKills({ systemId, outCommand, showAllVisible = false, sinceHours = 24 }: UseSystemKillsProps) { + const { data, update } = useMapRootState(); + const { detailedKills = {}, systems = [] } = data; + + const [settings] = useKillsWidgetSettings(); + const excludedSystems = settings.excludedSystems; + + const visibleSystemIds = useMemo(() => { + return systems.map(s => s.id).filter(id => !excludedSystems.includes(Number(id))); + }, [systems, excludedSystems]); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const didFallbackFetch = useRef(Object.keys(detailedKills).length !== 0); + + const mergeKillsIntoGlobal = useCallback( + (killsMap: Record) => { + update(prev => { + const oldMap = prev.detailedKills ?? {}; + const updated: Record = { ...oldMap }; + + for (const [sid, newKills] of Object.entries(killsMap)) { + const existing = updated[sid] ?? []; + const combined = combineKills(existing, newKills, sinceHours); + updated[sid] = combined; + } + + return { + ...prev, + detailedKills: updated, + }; + }); + }, + [update, sinceHours], + ); + + const fetchKills = useCallback( + async (forceFallback = false) => { + setIsLoading(true); + setError(null); + + try { + let eventType: OutCommand; + let requestData: Record; + + if (showAllVisible || forceFallback) { + eventType = OutCommand.getSystemsKills; + requestData = { + system_ids: visibleSystemIds, + since_hours: sinceHours, + }; + } else if (systemId) { + eventType = OutCommand.getSystemKills; + requestData = { + system_id: systemId, + since_hours: sinceHours, + }; + } else { + // If there's no system and not showing all, do nothing + setIsLoading(false); + return; + } + + const resp = await outCommand({ + type: eventType, + data: requestData, + }); + + // Single system => `resp.kills` + if (resp.kills) { + const arr = resp.kills as DetailedKill[]; + const sid = systemId ?? 'unknown'; + mergeKillsIntoGlobal({ [sid]: arr }); + } + // multiple => `resp.systems_kills` + else if (resp.systems_kills) { + mergeKillsIntoGlobal(resp.systems_kills as Record); + } else { + console.warn('[useSystemKills] Unexpected kills response =>', resp); + } + } catch (err) { + console.error('[useSystemKills] Failed to fetch kills:', err); + setError(err instanceof Error ? err.message : 'Error fetching kills'); + } finally { + setIsLoading(false); + } + }, + [showAllVisible, systemId, outCommand, visibleSystemIds, sinceHours, mergeKillsIntoGlobal], + ); + + const debouncedFetchKills = useMemo( + () => + debounce(fetchKills, 500, { + leading: true, + trailing: false, + }), + [fetchKills], + ); + + const finalKills = useMemo(() => { + if (showAllVisible) { + return visibleSystemIds.flatMap(sid => detailedKills[sid] ?? []); + } else if (systemId) { + return detailedKills[systemId] ?? []; + } else if (didFallbackFetch.current) { + // if we already did a fallback, we may have data for multiple systems + return visibleSystemIds.flatMap(sid => detailedKills[sid] ?? []); + } + return []; + }, [showAllVisible, systemId, didFallbackFetch, visibleSystemIds, detailedKills]); + + const effectiveIsLoading = isLoading && finalKills.length === 0; + + useEffect(() => { + if (!systemId && !showAllVisible && !didFallbackFetch.current) { + didFallbackFetch.current = true; + // Cancel any queued debounced calls, then do the fallback. + debouncedFetchKills.cancel(); + fetchKills(true); // forceFallback => fetch as though showAll + } + }, [systemId, showAllVisible, debouncedFetchKills, fetchKills, didFallbackFetch]); + + useEffect(() => { + if (visibleSystemIds.length === 0) return; + + if (showAllVisible || systemId) { + debouncedFetchKills(); + // Clean up the debounce on unmount or changes + return () => debouncedFetchKills.cancel(); + } + }, [showAllVisible, systemId, visibleSystemIds, debouncedFetchKills]); + + const refetch = useCallback(() => { + debouncedFetchKills.cancel(); + fetchKills(); // immediate (non-debounced) call + }, [debouncedFetchKills, fetchKills]); + + return { + kills: finalKills, + isLoading: effectiveIsLoading, + error, + refetch, + }; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/index.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/index.ts new file mode 100644 index 00000000..9b1dff75 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemKills/index.ts @@ -0,0 +1 @@ +export * from './SystemKills'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts index 4397021d..68434b38 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts @@ -3,3 +3,4 @@ export * from './SystemInfo'; export * from './RoutesWidget'; export * from './SystemSignatures'; export * from './SystemStructures'; +export * from './SystemKills'; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/WidgetsSettings/WidgetsSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/WidgetsSettings/WidgetsSettings.tsx index df393ee0..f2cc275c 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/WidgetsSettings/WidgetsSettings.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/WidgetsSettings/WidgetsSettings.tsx @@ -1,5 +1,5 @@ import { PrettySwitchbox } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/components'; -import { WIDGETS_CHECKBOXES_PROPS, WidgetsIds } from '@/hooks/Mapper/components/mapInterface/constants.tsx'; +import { getWidgetsCheckboxesProps, WidgetsIds } from '@/hooks/Mapper/components/mapInterface/constants.tsx'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { useCallback } from 'react'; @@ -9,17 +9,20 @@ export interface WidgetsSettingsProps {} // eslint-disable-next-line no-empty-pattern export const WidgetsSettings = ({}: WidgetsSettingsProps) => { - const { windowsSettings, toggleWidgetVisibility, resetWidgets } = useMapRootState(); + const { windowsSettings, toggleWidgetVisibility, resetWidgets, data } = useMapRootState(); const handleWidgetSettingsChange = useCallback( (widget: WidgetsIds) => toggleWidgetVisibility(widget), [toggleWidgetVisibility], ); + const detailedKillsDisabled = data.options?.detailedKillsDisabled === true; + const widgetProps = getWidgetsCheckboxesProps(detailedKillsDisabled); + return (
- {WIDGETS_CHECKBOXES_PROPS.map(widget => ( + {widgetProps.map(widget => ( { return name .replace(SHIP_NAME_RX, '') - .replace(/\\u([\dA-Fa-f]{4})/g, (_, grp) => { - return String.fromCharCode(parseInt(grp, 16)); - }) - .replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => { - return String.fromCharCode(parseInt(grp, 16)); - }); + .replace(/\\u([\dA-Fa-f]{4})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16))) + .replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16))); }; export const CharacterCard = ({ @@ -47,7 +43,9 @@ export const CharacterCard = ({ {!compact && ( )}
diff --git a/assets/js/hooks/Mapper/components/ui-kit/WdTooltip/WdTooltip.tsx b/assets/js/hooks/Mapper/components/ui-kit/WdTooltip/WdTooltip.tsx index d9419964..4acbf43a 100644 --- a/assets/js/hooks/Mapper/components/ui-kit/WdTooltip/WdTooltip.tsx +++ b/assets/js/hooks/Mapper/components/ui-kit/WdTooltip/WdTooltip.tsx @@ -1,20 +1,8 @@ -import React, { - ForwardedRef, - forwardRef, - MouseEvent, - MouseEventHandler, - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react'; +import React, { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; - -import classes from './WdTooltip.module.scss'; import clsx from 'clsx'; import debounce from 'lodash.debounce'; -import { WithClassName } from '@/hooks/Mapper/types/common.ts'; +import classes from './WdTooltip.module.scss'; export enum TooltipPosition { default = 'default', @@ -29,11 +17,7 @@ export interface TooltipProps { offset?: number; content: (() => React.ReactNode) | React.ReactNode; targetSelector?: string; -} - -export interface WdTooltipHandlers { - show: MouseEventHandler; - hide: MouseEventHandler; + interactive?: boolean; } export interface OffsetPosition { @@ -41,169 +25,185 @@ export interface OffsetPosition { left: number; } -// eslint-disable-next-line react/display-name -export const WdTooltip = forwardRef((props: TooltipProps & WithClassName, ref: ForwardedRef) => { - const { content, targetSelector, position: tPosition = TooltipPosition.default, className, offset = 5 } = props; +export interface WdTooltipHandlers { + show: (e?: React.MouseEvent) => void; + hide: (e?: React.MouseEvent) => void; + getIsMouseInside: () => boolean; +} - const [visible, setVisible] = useState(false); - const [position, setPosition] = useState(null); - const [ev, setEv] = useState(); - const tooltipRef = useRef(null); +export const WdTooltip = forwardRef( + (props: TooltipProps & { className?: string }, ref: ForwardedRef) => { + const { + content, + targetSelector, + position: tPosition = TooltipPosition.default, + className, + offset = 5, + interactive = false, + } = props; - const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => { - let newLeft = x; - let newTop = y; + const [visible, setVisible] = useState(false); + const [pos, setPos] = useState(null); + const [ev, setEv] = useState(); + const tooltipRef = useRef(null); + const [isMouseInsideTooltip, setIsMouseInsideTooltip] = useState(false); - if (!tooltipRef.current) { + const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => { + if (!tooltipRef.current) return { left: x, top: y }; + const tooltipWidth = tooltipRef.current.offsetWidth; + const tooltipHeight = tooltipRef.current.offsetHeight; + let newLeft = x; + let newTop = y; + + if (newLeft < 0) newLeft = 10; + if (newTop < 0) newTop = 10; + if (newLeft + tooltipWidth + 10 > window.innerWidth) { + newLeft = window.innerWidth - tooltipWidth - 10; + } + if (newTop + tooltipHeight + 10 > window.innerHeight) { + newTop = window.innerHeight - tooltipHeight - 10; + } return { left: newLeft, top: newTop }; - } + }, []); - const tooltipWidth = tooltipRef.current.offsetWidth; - const tooltipHeight = tooltipRef.current.offsetHeight; + useImperativeHandle(ref, () => ({ + show: (mouseEvt?: React.MouseEvent) => { + if (mouseEvt) setEv(mouseEvt); + setPos(null); + setVisible(true); + }, + hide: () => { + setVisible(false); + }, + getIsMouseInside: () => isMouseInsideTooltip, + })); - if (newLeft < 0) { - newLeft = 10; - } + useEffect(() => { + if (!tooltipRef.current || !ev) return; + const tooltipEl = tooltipRef.current; + const { clientX, clientY, target } = ev; + const targetBounds = (target as HTMLElement).getBoundingClientRect(); - if (newTop < 0) { - newTop = 10; - } + let offsetX = clientX; + let offsetY = clientY; - if (newLeft + tooltipWidth + 10 > window.innerWidth) { - newLeft = window.innerWidth - tooltipWidth - 10; - } - if (newTop + tooltipHeight + 10 > window.innerHeight) { - newTop = window.innerHeight - tooltipHeight - 10; - } - return { left: newLeft, top: newTop }; - }, []); + if (tPosition === TooltipPosition.left) { + const tooltipBounds = tooltipEl.getBoundingClientRect(); + offsetX = targetBounds.left - tooltipBounds.width - offset; + offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2; + if (offsetX <= 0) { + offsetX = targetBounds.left + targetBounds.width + offset; + } + setPos(calcTooltipPosition({ x: offsetX, y: offsetY })); + return; + } - useEffect(() => { - if (!tooltipRef.current || !ev) { - return; - } - - const { clientX, clientY, target } = ev; - - const targetBounds = (target as HTMLElement).getBoundingClientRect(); - const tooltipBounds = tooltipRef.current.getBoundingClientRect(); - - let offsetX = clientX; - let offsetY = clientY; - - if (tPosition === TooltipPosition.left) { - offsetX = targetBounds.left - tooltipBounds.width - offset; - offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2; - - if (offsetX <= 0) { + if (tPosition === TooltipPosition.right) { offsetX = targetBounds.left + targetBounds.width + offset; - } - - setPosition(calcTooltipPosition({ x: offsetX, y: offsetY })); - return; - } - - if (tPosition === TooltipPosition.right) { - offsetX = targetBounds.left + targetBounds.width + offset; - offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2; - - setPosition(calcTooltipPosition({ x: offsetX, y: offsetY })); - return; - } - - if (tPosition === TooltipPosition.top) { - offsetY = targetBounds.top - tooltipBounds.height - offset; - offsetX = targetBounds.x + targetBounds.width / 2 - tooltipBounds.width / 2; - - setPosition(calcTooltipPosition({ x: offsetX, y: offsetY })); - return; - } - - // default case - setPosition(calcTooltipPosition({ x: clientX, y: clientY })); - }, [calcTooltipPosition, ev, tPosition, offset]); - - useImperativeHandle(ref, () => ({ - show: e => { - setEv(e); - setVisible(true); - setPosition(null); - }, - hide: () => { - setVisible(false); - }, - })); - - useEffect(() => { - if (targetSelector == null) { - return; - } - - const handleMouseMove = (e: MouseEvent) => { - const targetElement = e.target as HTMLElement; - - if (!targetElement) { - setVisible(false); + offsetY = targetBounds.y + targetBounds.height / 2 - tooltipEl.offsetHeight / 2; + setPos(calcTooltipPosition({ x: offsetX, y: offsetY })); return; } - const nodesFound = [...(targetElement?.parentElement?.querySelectorAll(targetSelector) ?? [])]; - - if (!nodesFound.includes(targetElement)) { - setVisible(false); + if (tPosition === TooltipPosition.top) { + offsetY = targetBounds.top - tooltipEl.offsetHeight - offset; + offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2; + setPos(calcTooltipPosition({ x: offsetX, y: offsetY })); return; } - setVisible(true); - if (tooltipRef.current) { - const { clientX, clientY } = e; - const tooltipWidth = tooltipRef.current.offsetWidth; - const tooltipHeight = tooltipRef.current.offsetHeight; - let newLeft = clientX + 10; - let newTop = clientY + 10; - if (newLeft + tooltipWidth + 10 > window.innerWidth) { - newLeft = window.innerWidth - tooltipWidth - 10; - } - if (newTop + tooltipHeight + 10 > window.innerHeight) { - newTop = window.innerHeight - tooltipHeight - 10; - } - setPosition({ top: newTop, left: newLeft }); + if (tPosition === TooltipPosition.bottom) { + offsetY = targetBounds.bottom + offset; + offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2; + setPos(calcTooltipPosition({ x: offsetX, y: offsetY })); + return; } - }; - const deb = debounce(handleMouseMove, 10); + setPos(calcTooltipPosition({ x: offsetX, y: offsetY })); + }, [calcTooltipPosition, ev, tPosition, offset]); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - document.addEventListener('mousemove', deb); - return () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - document.removeEventListener('mousemove', deb); - }; - }, [targetSelector]); + useEffect(() => { + if (!targetSelector) return; - return createPortal( - visible && ( -
- {typeof content === 'function' ? content() : content} -
- ), - document.body, - ); -}); + function handleMouseMove(nativeEvt: globalThis.MouseEvent) { + const targetEl = nativeEvt.target as HTMLElement | null; + if (!targetEl) { + setVisible(false); + return; + } + const triggerEl = targetEl.closest(targetSelector!); + const isInsideTooltip = interactive && tooltipRef.current?.contains(targetEl); + + if (!triggerEl && !isInsideTooltip) { + setVisible(false); + return; + } + setVisible(true); + + if (triggerEl && tooltipRef.current) { + const rect = triggerEl.getBoundingClientRect(); + const tooltipEl = tooltipRef.current; + let x = nativeEvt.clientX; + let y = nativeEvt.clientY; + + if (tPosition === TooltipPosition.left) { + x = rect.left - tooltipEl.offsetWidth - offset; + y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2; + if (x <= 0) { + x = rect.left + rect.width + offset; + } + } else if (tPosition === TooltipPosition.right) { + x = rect.left + rect.width + offset; + y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2; + } else if (tPosition === TooltipPosition.top) { + x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2; + y = rect.top - tooltipEl.offsetHeight - offset; + } else if (tPosition === TooltipPosition.bottom) { + x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2; + y = rect.bottom + offset; + } + + setPos(calcTooltipPosition({ x, y })); + } + } + + const debounced = debounce(handleMouseMove, 10); + + const listener: EventListener = evt => { + debounced(evt as globalThis.MouseEvent); + }; + + document.addEventListener('mousemove', listener); + return () => { + document.removeEventListener('mousemove', listener); + }; + }, [targetSelector, interactive, tPosition, offset, calcTooltipPosition]); + + return createPortal( + visible && ( +
interactive && setIsMouseInsideTooltip(true)} + onMouseLeave={() => interactive && setIsMouseInsideTooltip(false)} + > + {typeof content === 'function' ? content() : content} +
+ ), + document.body, + ); + }, +); + +WdTooltip.displayName = 'WdTooltip'; diff --git a/assets/js/hooks/Mapper/components/ui-kit/WdTooltipWrapper/WdTooltipWrapper.tsx b/assets/js/hooks/Mapper/components/ui-kit/WdTooltipWrapper/WdTooltipWrapper.tsx index 127d99cf..c2b4eacb 100644 --- a/assets/js/hooks/Mapper/components/ui-kit/WdTooltipWrapper/WdTooltipWrapper.tsx +++ b/assets/js/hooks/Mapper/components/ui-kit/WdTooltipWrapper/WdTooltipWrapper.tsx @@ -1,49 +1,57 @@ -import React, { HTMLProps, MouseEventHandler, useCallback, useRef } from 'react'; - -import classes from './WdTooltipWrapper.module.scss'; -import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common.ts'; -import { TooltipProps, WdTooltip, WdTooltipHandlers } from '@/hooks/Mapper/components/ui-kit'; +import { forwardRef, HTMLProps, ReactNode } from 'react'; import clsx from 'clsx'; +import { WdTooltip, WdTooltipHandlers, TooltipProps } from '@/hooks/Mapper/components/ui-kit'; +import classes from './WdTooltipWrapper.module.scss'; + +type TooltipSize = 'xs' | 'sm' | 'md' | 'lg'; export type WdTooltipWrapperProps = { - content?: (() => React.ReactNode) | React.ReactNode; -} & WithChildren & - WithClassName & - HTMLProps & + content?: (() => ReactNode) | ReactNode; + size?: TooltipSize; + interactive?: boolean; +} & Omit, 'content' | 'size'> & Omit; -export const WdTooltipWrapper = ({ - className, - children, - content, - offset, - position, - targetSelector, - ...props -}: WdTooltipWrapperProps) => { - const tooltipRef = useRef(null); - const handleShowDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.show(e), []); - const handleHideDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.hide(e), []); +export const WdTooltipWrapper = forwardRef( + ( + { className, children, content, offset, position, targetSelector, interactive = false, size, ...props }, + forwardedRef, + ) => { + const suffix = Math.random().toString(36).slice(2, 7); + const autoClass = `wdTooltipAutoTrigger-${suffix}`; + const finalTargetSelector = targetSelector || `.${autoClass}`; - return ( - <> -
- {children} + return ( +
+ {targetSelector ? <>{children} :
{children}
} + +
- - - ); -}; + ); + }, +); + +WdTooltipWrapper.displayName = 'WdTooltipWrapper'; + +function sizeClass(size: TooltipSize) { + switch (size) { + case 'xs': + return classes.wdTooltipSizeXs; + case 'sm': + return classes.wdTooltipSizeSm; + case 'md': + return classes.wdTooltipSizeMd; + case 'lg': + return classes.wdTooltipSizeLg; + default: + return undefined; + } +} diff --git a/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx b/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx index a176673a..45478444 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx +++ b/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx @@ -11,11 +11,13 @@ import { WindowStoreInfo, } from '@/hooks/Mapper/mapRootProvider/hooks/useStoreWidgets.ts'; import { CommandLinkSignatureToSystem } from '@/hooks/Mapper/types'; +import { DetailedKill } from '../types/kills'; export type MapRootData = MapUnionTypes & { selectedSystems: string[]; selectedConnections: Pick[]; linkSignatureToSystem: CommandLinkSignatureToSystem | null; + detailedKills: Record; }; const INITIAL_DATA: MapRootData = { @@ -31,7 +33,7 @@ const INITIAL_DATA: MapRootData = { routes: undefined, kills: [], connections: [], - + detailedKills: {}, selectedSystems: [], selectedConnections: [], userPermissions: {}, diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsSystems.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsSystems.ts index 125df212..60f43bce 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsSystems.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsSystems.ts @@ -10,17 +10,18 @@ import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoa import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts'; import { emitMapEvent } from '@/hooks/Mapper/events'; import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts'; +import { DetailedKill } from '@/hooks/Mapper/types/kills'; export const useCommandsSystems = () => { const { update, - data: { systems, systemSignatures }, + data: { systems, systemSignatures, detailedKills }, outCommand, } = useMapRootState(); const { addSystemStatic } = useLoadSystemStatic({ systems: [] }); - const ref = useRef({ systems, systemSignatures, update, addSystemStatic }); - ref.current = { systems, systemSignatures, update, addSystemStatic }; + const ref = useRef({ systems, systemSignatures, update, addSystemStatic, detailedKills }); + ref.current = { systems, systemSignatures, update, addSystemStatic, detailedKills }; const addSystems = useCallback((systemsToAdd: CommandAddSystems) => { const { update, addSystemStatic, systems } = ref.current; @@ -84,5 +85,23 @@ export const useCommandsSystems = () => { update({ linkSignatureToSystem: command }, true); }, []); - return { addSystems, removeSystems, updateSystems, updateSystemSignatures, updateLinkSignatureToSystem }; + const updateDetailedKills = useCallback((newKillsMap: Record) => { + const { update, detailedKills } = ref.current; + + const updated = { ...detailedKills }; + for (const [systemId, killsArr] of Object.entries(newKillsMap)) { + updated[systemId] = killsArr; + } + + update({ detailedKills: updated }, true); + }, []); + + return { + addSystems, + removeSystems, + updateSystems, + updateSystemSignatures, + updateLinkSignatureToSystem, + updateDetailedKills, + }; }; diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts index 7ce3c325..83eb4892 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts @@ -7,6 +7,7 @@ import { CommandCharactersUpdated, CommandCharacterUpdated, CommandInit, + CommandLinkSignatureToSystem, CommandMapUpdated, CommandPresentCharacters, CommandRemoveConnections, @@ -29,11 +30,18 @@ import { } from './api'; import { emitMapEvent } from '@/hooks/Mapper/events'; +import { DetailedKill } from '../../types/kills'; export const useMapRootHandlers = (ref: ForwardedRef) => { const mapInit = useMapInit(); - const { addSystems, removeSystems, updateSystems, updateSystemSignatures, updateLinkSignatureToSystem } = - useCommandsSystems(); + const { + addSystems, + removeSystems, + updateSystems, + updateSystemSignatures, + updateLinkSignatureToSystem, + updateDetailedKills, + } = useCommandsSystems(); const { addConnections, removeConnections, updateConnection } = useCommandsConnections(); const { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters } = useCommandsCharacters(); @@ -111,6 +119,10 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { // do nothing here break; + case Commands.detailedKillsUpdated: + updateDetailedKills(data as Record); + break; + default: console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data); break; diff --git a/assets/js/hooks/Mapper/types/kills.ts b/assets/js/hooks/Mapper/types/kills.ts index 44721a1d..1fb6a671 100644 --- a/assets/js/hooks/Mapper/types/kills.ts +++ b/assets/js/hooks/Mapper/types/kills.ts @@ -2,3 +2,33 @@ export type Kill = { solar_system_id: number; kills: number; }; + +export interface DetailedKill { + killmail_id: number; + solar_system_id: number; + kill_time?: string; + + zkb?: Record; + + victim_char_id?: number | null; + victim_char_name?: string; + victim_corp_id?: number | null; + victim_corp_ticker?: string; + victim_alliance_id?: number | null; + victim_alliance_ticker?: string; + victim_ship_type_id?: number | null; + victim_ship_name?: string; + + final_blow_char_id?: number | null; + final_blow_char_name?: string; + final_blow_corp_id?: number | null; + final_blow_corp_ticker?: string; + final_blow_alliance_id?: number | null; + final_blow_alliance_ticker?: string; + final_blow_ship_type_id?: number | null; + final_blow_ship_name?: string; + + attacker_count?: number | null; + total_value?: number | null; + npc?: boolean; +} diff --git a/assets/js/hooks/Mapper/types/mapHandlers.ts b/assets/js/hooks/Mapper/types/mapHandlers.ts index e20a6b16..c4bcfbfb 100644 --- a/assets/js/hooks/Mapper/types/mapHandlers.ts +++ b/assets/js/hooks/Mapper/types/mapHandlers.ts @@ -3,8 +3,8 @@ import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts'; import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes.ts'; import { CharacterTypeRaw } from '@/hooks/Mapper/types/character.ts'; import { RoutesList } from '@/hooks/Mapper/types/routes.ts'; -import { Kill } from '@/hooks/Mapper/types/kills.ts'; -import { UserPermissions } from '@/hooks/Mapper/types'; +import { DetailedKill, Kill } from '@/hooks/Mapper/types/kills.ts'; +import { SignatureGroup, UserPermissions } from '@/hooks/Mapper/types'; export enum Commands { init = 'init', @@ -21,6 +21,7 @@ export enum Commands { updateConnection = 'update_connection', mapUpdated = 'map_updated', killsUpdated = 'kills_updated', + detailedKillsUpdated = 'detailed_kills_updated', routes = 'routes', centerSystem = 'center_system', selectSystem = 'select_system', @@ -43,6 +44,7 @@ export type Command = | Commands.updateConnection | Commands.mapUpdated | Commands.killsUpdated + | Commands.detailedKillsUpdated | Commands.routes | Commands.selectSystem | Commands.centerSystem @@ -76,9 +78,11 @@ export type CommandCharacterRemoved = CharacterTypeRaw; export type CommandCharacterUpdated = CharacterTypeRaw; export type CommandPresentCharacters = string[]; export type CommandUpdateConnection = SolarSystemConnection; +export type CommandSignaturesUpdated = string; export type CommandMapUpdated = Partial; export type CommandRoutes = RoutesList; export type CommandKillsUpdated = Kill[]; +export type CommandDetailedKillsUpdated = Record; export type CommandSelectSystem = string | undefined; export type CommandCenterSystem = string | undefined; export type CommandLinkSignatureToSystem = { @@ -103,6 +107,7 @@ export interface CommandData { [Commands.mapUpdated]: CommandMapUpdated; [Commands.routes]: CommandRoutes; [Commands.killsUpdated]: CommandKillsUpdated; + [Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated; [Commands.selectSystem]: CommandSelectSystem; [Commands.centerSystem]: CommandCenterSystem; [Commands.linkSignatureToSystem]: CommandLinkSignatureToSystem; @@ -151,6 +156,8 @@ export enum OutCommand { linkSignatureToSystem = 'link_signature_to_system', getCorporationNames = 'get_corporation_names', getCorporationTicker = 'get_corporation_ticker', + getSystemKills = 'get_system_kills', + getSystemsKills = 'get_systems_kills', // Only UI commands openSettings = 'open_settings', @@ -161,4 +168,5 @@ export enum OutCommand { searchSystems = 'search_systems', } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type OutCommandHandler = (event: { type: OutCommand; data: any }) => Promise; diff --git a/assets/js/hooks/Mapper/types/system.ts b/assets/js/hooks/Mapper/types/system.ts index 8065eb53..0c33daa9 100644 --- a/assets/js/hooks/Mapper/types/system.ts +++ b/assets/js/hooks/Mapper/types/system.ts @@ -1,6 +1,7 @@ import { XYPosition } from 'reactflow'; import { SystemSignature } from '@/hooks/Mapper/types/signatures'; +import { DetailedKill } from './kills'; export enum SolarSystemStaticInfoRawNames { regionId = 'region_id', diff --git a/config/runtime.exs b/config/runtime.exs index 7ac19594..c1df084a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -53,6 +53,11 @@ public_api_disabled = |> get_var_from_path_or_env("WANDERER_PUBLIC_API_DISABLED", "false") |> String.to_existing_atom() +zkill_preload_disabled = + config_dir + |> get_var_from_path_or_env("WANDERER_ZKILL_PRELOAD_DISABLED", "false") + |> String.to_existing_atom() + map_subscriptions_enabled = config_dir |> get_var_from_path_or_env("WANDERER_MAP_SUBSCRIPTIONS_ENABLED", "false") @@ -113,6 +118,7 @@ config :wanderer_app, corp_id: System.get_env("WANDERER_CORP_ID", "-1") |> String.to_integer(), corp_wallet: System.get_env("WANDERER_CORP_WALLET", ""), public_api_disabled: public_api_disabled, + zkill_preload_disabled: zkill_preload_disabled, map_subscriptions_enabled: map_subscriptions_enabled, map_connection_auto_expire_hours: map_connection_auto_expire_hours, map_connection_auto_eol_hours: map_connection_auto_eol_hours, diff --git a/lib/wanderer_app/api/map.ex b/lib/wanderer_app/api/map.ex index 036a4a87..37f36a25 100644 --- a/lib/wanderer_app/api/map.ex +++ b/lib/wanderer_app/api/map.ex @@ -127,7 +127,6 @@ defmodule WandererApp.Api.Map do update :update_api_key do accept [:public_api_key] end - end attributes do diff --git a/lib/wanderer_app/application.ex b/lib/wanderer_app/application.ex index 559f1f6c..7608f121 100644 --- a/lib/wanderer_app/application.ex +++ b/lib/wanderer_app/application.ex @@ -13,39 +13,48 @@ defmodule WandererApp.Application do WandererAppWeb.Telemetry, WandererApp.Vault, WandererApp.Repo, + {Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2}, - {Finch, name: WandererApp.Finch}, + + { + Finch, + name: WandererApp.Finch, + pools: %{ + default: [ + size: 25, # number of connections per pool + count: 2, # number of pools (so total 50 connections) + ] + } + }, + WandererApp.Cache, - Supervisor.child_spec({Cachex, name: :system_static_info_cache}, - id: :system_static_info_cache_worker - ), - Supervisor.child_spec({Cachex, name: :ship_types_cache}, - id: :ship_types_cache_worker - ), - Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker), - Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker), - Supervisor.child_spec({Cachex, name: :character_state_cache}, - id: :character_state_cache_worker - ), + + Supervisor.child_spec({Cachex, name: :system_static_info_cache}, id: :system_static_info_cache_worker), + Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker), + Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker), + Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker), + Supervisor.child_spec({Cachex, name: :character_state_cache}, id: :character_state_cache_worker), + WandererApp.Scheduler, + {Registry, keys: :unique, name: WandererApp.MapRegistry}, {Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry}, - {PartitionSupervisor, - child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors}, - {PartitionSupervisor, - child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors}, + + {PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors}, + {PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors}, + WandererApp.Zkb.Supervisor, WandererApp.Server.ServerStatusTracker, WandererApp.Server.TheraDataFetcher, WandererApp.Character.TrackerManager, WandererApp.Map.Manager, WandererApp.Map.ZkbDataFetcher, + WandererAppWeb.Presence, WandererAppWeb.Endpoint - ] ++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?()) + ] + ++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?()) - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options opts = [strategy: :one_for_one, name: WandererApp.Supervisor] Supervisor.start_link(children, opts) @@ -59,8 +68,6 @@ defmodule WandererApp.Application do end end - # Tell Phoenix to update the endpoint configuration - # whenever the application is updated. @impl true def config_change(changed, _new, removed) do WandererAppWeb.Endpoint.config_change(changed, removed) @@ -72,5 +79,6 @@ defmodule WandererApp.Application do WandererApp.StartCorpWalletTrackerTask ] - defp maybe_start_corp_wallet_tracker(_), do: [] + defp maybe_start_corp_wallet_tracker(_), + do: [] end diff --git a/lib/wanderer_app/character/tracker.ex b/lib/wanderer_app/character/tracker.ex index 433f9325..1f6b3caf 100644 --- a/lib/wanderer_app/character/tracker.ex +++ b/lib/wanderer_app/character/tracker.ex @@ -71,7 +71,7 @@ defmodule WandererApp.Character.Tracker do {:ok, %{eve_id: eve_id}} = WandererApp.Character.get_character(character_id) case WandererApp.Esi.get_character_info(eve_id) do - {:ok, info} -> + {:ok, _info} -> {:ok, character_state} = WandererApp.Character.get_character_state(character_id) update = maybe_update_corporation(character_state, eve_id |> String.to_integer()) diff --git a/lib/wanderer_app/env.ex b/lib/wanderer_app/env.ex index 1c0e8f2c..36a4d11c 100644 --- a/lib/wanderer_app/env.ex +++ b/lib/wanderer_app/env.ex @@ -11,6 +11,7 @@ defmodule WandererApp.Env do def invites, do: get_key(:invites, false) def map_subscriptions_enabled?, do: get_key(:map_subscriptions_enabled, false) def public_api_disabled?, do: get_key(:public_api_disabled, false) + def zkill_preload_disabled?, do: get_key(:zkill_preload_disabled, false) def wallet_tracking_enabled?, do: get_key(:wallet_tracking_enabled, false) def admins, do: get_key(:admins, []) def admin_username, do: get_key(:admin_username) @@ -39,4 +40,12 @@ defmodule WandererApp.Env do do: get_key(:map_connection_eol_expire_timeout_mins) def get_key(key, default \\ nil), do: Application.get_env(@app, key, default) + + @doc """ + A single map containing environment variables + made available to react + """ + def to_client_env do + %{detailedKillsDisabled: zkill_preload_disabled?()} + end end diff --git a/lib/wanderer_app/esi.ex b/lib/wanderer_app/esi.ex index 8e52d41f..c013f2a8 100644 --- a/lib/wanderer_app/esi.ex +++ b/lib/wanderer_app/esi.ex @@ -24,6 +24,9 @@ defmodule WandererApp.Esi do defdelegate find_routes(map_id, origin, hubs, routes_settings), to: WandererApp.Esi.ApiClient defdelegate search(character_eve_id, opts \\ []), to: WandererApp.Esi.ApiClient + defdelegate get_killmail(killmail_id, killmail_hash, opts \\ []), to: WandererApp.Esi.ApiClient + + defdelegate set_autopilot_waypoint( add_to_beginning, clear_other_waypoints, diff --git a/lib/wanderer_app/esi/api_client.ex b/lib/wanderer_app/esi/api_client.ex index a2cd037d..55bd594f 100644 --- a/lib/wanderer_app/esi/api_client.ex +++ b/lib/wanderer_app/esi/api_client.ex @@ -289,6 +289,16 @@ defmodule WandererApp.Esi.ApiClient do end end + + @decorate cacheable( + cache: Cache, + key: "killmail-#{killmail_id}-#{killmail_hash}", + opts: [ttl: @ttl] + ) + def get_killmail(killmail_id, killmail_hash, opts \\ []) do + get("/killmails/#{killmail_id}/#{killmail_hash}/", _with_cache_opts(opts)) + end + @decorate cacheable( cache: Cache, key: "info-#{eve_id}", diff --git a/lib/wanderer_app/map/map_zkb_data_fetcher.ex b/lib/wanderer_app/map/map_zkb_data_fetcher.ex index caf2cc71..07459b14 100644 --- a/lib/wanderer_app/map/map_zkb_data_fetcher.ex +++ b/lib/wanderer_app/map/map_zkb_data_fetcher.ex @@ -6,114 +6,205 @@ defmodule WandererApp.Map.ZkbDataFetcher do require Logger + alias WandererApp.Zkb.KillsProvider.KillsCache + @interval :timer.seconds(15) @store_map_kills_timeout :timer.hours(1) @logger Application.compile_env(:wanderer_app, :logger) @pubsub_client Application.compile_env(:wanderer_app, :pubsub_client) + # This means 120 “ticks” of 15s each → ~30 minutes + @preload_cycle_ticks 120 + def start_link(_) do - GenServer.start(__MODULE__, [], name: __MODULE__) + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end @impl true - def init([]) do - {:ok, timer} = :timer.send_interval(@interval, :fetch_data) - - {:ok, %{timer: timer}} + def init(_arg) do + {:ok, _timer_ref} = :timer.send_interval(@interval, :fetch_data) + {:ok, %{iteration: 0}} end @impl true - def handle_info(:fetch_data, state) do + def handle_info(:fetch_data, %{iteration: iteration} = state) do WandererApp.Map.RegistryHelper.list_all_maps() |> Task.async_stream( fn %{id: map_id, pid: _server_pid} -> try do - map_id - |> WandererApp.Map.Server.map_pid() - |> case do - pid when is_pid(pid) -> - _update_map_kills(map_id) + if WandererApp.Map.Server.map_pid(map_id) do + update_map_kills(map_id) - nil -> - :ok + unless WandererApp.Env.zkill_preload_disabled?() do + update_detailed_map_kills(map_id) + end end rescue e -> @logger.error(Exception.message(e)) - :ok end end, max_concurrency: 10, on_timeout: :kill_task ) - |> Enum.map(fn _ -> :ok end) + |> Enum.each(fn _ -> :ok end) - {:noreply, state} - end + new_iteration = iteration + 1 - @impl true - def handle_info({ref, result}, state) do - Process.demonitor(ref, [:flush]) + cond do + WandererApp.Env.zkill_preload_disabled?() -> + # If preload is disabled, just update iteration + {:noreply, %{state | iteration: new_iteration}} - {:noreply, state} - end + new_iteration >= @preload_cycle_ticks -> + Logger.info("[ZkbDataFetcher] Triggering a fresh kill preload pass ...") + WandererApp.Zkb.KillsPreloader.run_preload_now() + {:noreply, %{state | iteration: 0}} - defp _update_map_kills(map_id) do - case WandererApp.Cache.lookup!("map_#{map_id}:started", false) do true -> - map_id - |> WandererApp.Map.get_map!() - |> Map.get(:systems, Map.new()) - |> Enum.reduce(Map.new(), fn {solar_system_id, _system}, acc -> - kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}") - acc |> Map.put(solar_system_id, kills_count || 0) - end) - |> _maybe_broadcast_map_kills(map_id) - - _ -> - :ok + {:noreply, %{state | iteration: new_iteration}} end end - defp _maybe_broadcast_map_kills(new_kills_map, map_id) do - {:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", Map.new()) + # Catch any async task results we aren't explicitly pattern-matching + @impl true + def handle_info({ref, _result}, state) do + Process.demonitor(ref, [:flush]) + {:noreply, state} + end - updated_kills_system_ids = - new_kills_map - |> Map.filter(fn {solar_system_id, new_kills_count} -> - old_kills_count = old_kills_map |> Map.get(solar_system_id, 0) - - new_kills_count != old_kills_count and - new_kills_count > 0 + defp update_map_kills(map_id) do + with_started_map(map_id, "basic kills update", fn -> + map_id + |> WandererApp.Map.get_map!() + |> Map.get(:systems, %{}) + |> Enum.into(%{}, fn {solar_system_id, _system} -> + kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}") || 0 + {solar_system_id, kills_count} end) - |> Map.keys() + |> maybe_broadcast_map_kills(map_id) + end) + end - removed_kills_system_ids = - old_kills_map - |> Map.filter(fn {solar_system_id, old_kills_count} -> - new_kills_count = new_kills_map |> Map.get(solar_system_id, 0) + defp update_detailed_map_kills(map_id) do + with_started_map(map_id, "detailed kills update", fn -> + systems = + map_id + |> WandererApp.Map.get_map!() + |> Map.get(:systems, %{}) - old_kills_count > 0 and new_kills_count == 0 - end) - |> Map.keys() + # Old cache data + old_ids_map = WandererApp.Cache.get("map_#{map_id}:zkb_ids") || %{} + old_details_map = WandererApp.Cache.get("map_#{map_id}:zkb_detailed_kills") || %{} - (updated_kills_system_ids ++ removed_kills_system_ids) - |> case do - [] -> + new_ids_map = + Enum.into(systems, %{}, fn {solar_system_id, _} -> + ids = KillsCache.get_system_killmail_ids(solar_system_id) |> MapSet.new() + {solar_system_id, ids} + end) + + 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) + end) + |> Enum.map(&elem(&1, 0)) + + if changed_systems == [] do + Logger.debug("[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}") :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() - system_ids -> - :ok = - WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map, - ttl: @store_map_kills_timeout - ) + kill_details = + kill_ids + |> Enum.map(&KillsCache.get_killmail/1) + |> Enum.reject(&is_nil/1) + + Map.put(acc, system_id, kill_details) + end) + + 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) + + WandererApp.Cache.put("map_#{map_id}:zkb_ids", updated_ids_map, + ttl: :timer.hours(KillsCache.killmail_ttl) + ) + + WandererApp.Cache.put("map_#{map_id}:zkb_detailed_kills", updated_details_map, + ttl: :timer.hours(KillsCache.killmail_ttl) + ) + + changed_data = Map.take(updated_details_map, changed_systems) @pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{ - event: :kills_updated, - payload: new_kills_map |> Map.take(system_ids) + event: :detailed_kills_updated, + payload: changed_data }) :ok + end + end) + end + + defp maybe_broadcast_map_kills(new_kills_map, map_id) do + {:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", %{}) + + updated_kills_system_ids = + old_kills_map + |> Map.keys() + |> Enum.filter(fn system_id -> + new_kills_count = Map.get(new_kills_map, system_id, 0) + old_kills_count = Map.get(old_kills_map, system_id, 0) + new_kills_count != old_kills_count and new_kills_count > 0 + end) + + removed_kills_system_ids = + old_kills_map + |> Map.keys() + |> Enum.filter(fn system_id -> + old_kills_count = Map.get(old_kills_map, system_id, 0) + new_kills_count = Map.get(new_kills_map, system_id, 0) + old_kills_count > 0 and new_kills_count == 0 + end) + + changed_system_ids = updated_kills_system_ids ++ removed_kills_system_ids + + if changed_system_ids == [] do + :ok + else + :ok = + WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map, + ttl: @store_map_kills_timeout + ) + + payload = Map.take(new_kills_map, changed_system_ids) + + @pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{ + event: :kills_updated, + payload: payload + }) + + :ok + end + end + + defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do + if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do + fun.() + else + Logger.debug("[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}") + :ok end end end diff --git a/lib/wanderer_app/zkb/zkb_kills_preloader.ex b/lib/wanderer_app/zkb/zkb_kills_preloader.ex new file mode 100644 index 00000000..89e74992 --- /dev/null +++ b/lib/wanderer_app/zkb/zkb_kills_preloader.ex @@ -0,0 +1,275 @@ +defmodule WandererApp.Zkb.KillsPreloader do + @moduledoc """ + On startup, kicks off two passes (quick and expanded) to preload kills data. + + There is also a `run_preload_now/0` function for manual triggering of the same logic. + """ + + use GenServer + require Logger + + alias WandererApp.Zkb.KillsProvider + alias WandererApp.Zkb.KillsProvider.KillsCache + + # ---------------- + # Configuration + # ---------------- + + # (1) Quick pass + @quick_limit 1 + @quick_hours 1 + + # (2) Expanded pass + @expanded_limit 25 + @expanded_hours 24 + + # How many minutes back we look for “last active” maps + @last_active_cutoff 30 + + # Default concurrency if not provided + @default_max_concurrency 2 + + @doc """ + Starts the GenServer with optional opts (like `max_concurrency`). + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Public helper to explicitly request a fresh preload pass (both quick & expanded). + """ + def run_preload_now() do + send(__MODULE__, :start_preload) + end + + @impl true + def init(opts) do + state = %{ + phase: :idle, + calls_count: 0, + max_concurrency: Keyword.get(opts, :max_concurrency, @default_max_concurrency) + } + + # Kick off the preload passes once at startup + send(self(), :start_preload) + {:ok, state} + end + + @impl true + def handle_info(:start_preload, state) do + # Gather last-active maps (or fallback). + cutoff_time = + DateTime.utc_now() + |> DateTime.add(-@last_active_cutoff, :minute) + + last_active_maps_result = WandererApp.Api.MapState.get_last_active(cutoff_time) + last_active_maps = resolve_last_active_maps(last_active_maps_result) + + # Gather systems from those maps + system_tuples = gather_visible_systems(last_active_maps) + unique_systems = Enum.uniq(system_tuples) + + Logger.debug(""" + [KillsPreloader] Found #{length(unique_systems)} unique systems \ + across #{length(last_active_maps)} map(s) + """) + + # ---- QUICK PASS ---- + state_quick = %{state | phase: :quick_pass} + + {time_quick_ms, state_after_quick} = + measure_execution_time(fn -> + do_pass(unique_systems, :quick, @quick_hours, @quick_limit, state_quick) + end) + + Logger.info("[KillsPreloader] Phase 1 (quick) done => calls_count=#{state_after_quick.calls_count}, elapsed=#{time_quick_ms}ms") + + # ---- EXPANDED PASS ---- + state_expanded = %{state_after_quick | phase: :expanded_pass} + + {time_expanded_ms, final_state} = + measure_execution_time(fn -> + do_pass(unique_systems, :expanded, @quick_hours, @expanded_limit, state_expanded) + end) + + Logger.info("[KillsPreloader] Phase 2 (expanded) done => calls_count=#{final_state.calls_count}, elapsed=#{time_expanded_ms}ms") + + # Reset phase to :idle + {:noreply, %{final_state | phase: :idle}} + end + + @impl true + def handle_info(_other, state), do: {:noreply, state} + + defp resolve_last_active_maps({:ok, []}) do + Logger.warning("[KillsPreloader] No last-active maps found. Using fallback logic...") + + case WandererApp.Maps.get_available_maps() do + {:ok, []} -> + Logger.error("[KillsPreloader] Fallback: get_available_maps returned zero maps!") + [] + + {:ok, maps} -> + # pick the newest map by updated_at + fallback_map = Enum.max_by(maps, & &1.updated_at, fn -> nil end) + if fallback_map, do: [fallback_map], else: [] + end + end + + defp resolve_last_active_maps({:ok, maps}) when is_list(maps), + do: maps + + defp resolve_last_active_maps({:error, reason}) do + Logger.error("[KillsPreloader] Could not load last-active maps => #{inspect(reason)}") + [] + end + + + defp gather_visible_systems(maps) do + maps + |> Enum.flat_map(fn map_record -> + the_map_id = Map.get(map_record, :map_id) || Map.get(map_record, :id) + + case WandererApp.MapSystemRepo.get_visible_by_map(the_map_id) do + {:ok, systems} -> + Enum.map(systems, fn sys -> {the_map_id, sys.solar_system_id} end) + + {:error, reason} -> + Logger.warning("[KillsPreloader] get_visible_by_map failed => map_id=#{inspect(the_map_id)}, reason=#{inspect(reason)}") + [] + end + end) + end + + + defp do_pass(unique_systems, pass_type, hours, limit, state) do + Logger.info("[KillsPreloader] Starting #{pass_type} pass => #{length(unique_systems)} systems") + + {final_state, kills_map} = + unique_systems + |> Task.async_stream( + fn {_map_id, system_id} -> + fetch_kills_for_system(system_id, pass_type, hours, limit, state) + end, + max_concurrency: state.max_concurrency, + timeout: pass_timeout_ms(pass_type) + ) + |> Enum.reduce({state, %{}}, fn task_result, {acc_state, acc_map} -> + reduce_task_result(pass_type, task_result, acc_state, acc_map) + end) + + if map_size(kills_map) > 0 do + broadcast_all_kills(kills_map, pass_type) + end + + final_state + end + + defp fetch_kills_for_system(system_id, :quick, hours, limit, state) do + Logger.debug("[KillsPreloader] Quick fetch => system=#{system_id}, hours=#{hours}, limit=#{limit}") + + case KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, limit: limit, force: false) do + {:ok, kills, updated_state} -> + {:ok, system_id, kills, updated_state} + + {:error, reason, updated_state} -> + Logger.warning("[KillsPreloader] Quick fetch failed => system=#{system_id}, reason=#{inspect(reason)}") + {:error, reason, updated_state} + end + end + + defp fetch_kills_for_system(system_id, :expanded, hours, limit, state) do + Logger.debug("[KillsPreloader] Expanded fetch => system=#{system_id}, hours=#{hours}, limit=#{limit} (forcing refresh)") + + with {:ok, kills_1h, updated_state} <- + KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, limit: limit, force: true), + {:ok, final_kills, final_state} <- + maybe_fetch_more_if_needed(system_id, kills_1h, limit, updated_state) do + {:ok, system_id, final_kills, final_state} + else + {:error, reason, updated_state} -> + Logger.warning("[KillsPreloader] Expanded fetch (#{hours}h) failed => system=#{system_id}, reason=#{inspect(reason)}") + {:error, reason, updated_state} + end + end + + # If we got fewer kills than `limit` from the 1h fetch, top up from 24h + defp maybe_fetch_more_if_needed(system_id, kills_1h, limit, state) do + if length(kills_1h) < limit do + needed = limit - length(kills_1h) + Logger.debug("[KillsPreloader] Expanding to #{@expanded_hours}h => system=#{system_id}, need=#{needed} more kills") + + case KillsProvider.Fetcher.fetch_kills_for_system(system_id, @expanded_hours, state, limit: needed, force: true) do + {:ok, _kills_24h, updated_state2} -> + final_kills = + KillsCache.fetch_cached_kills(system_id) + |> Enum.take(limit) + + {:ok, final_kills, updated_state2} + + {:error, reason2, updated_state2} -> + Logger.warning("[KillsPreloader] #{@expanded_hours}h fetch failed => system=#{system_id}, reason=#{inspect(reason2)}") + {:error, reason2, updated_state2} + end + else + {:ok, kills_1h, state} + end + end + + defp reduce_task_result(pass_type, task_result, acc_state, acc_map) do + case task_result do + {:ok, {:ok, sys_id, kills, updated_state}} -> + # Merge calls count from updated_state into acc_state + new_state = merge_calls_count(acc_state, updated_state) + new_map = Map.put(acc_map, sys_id, kills) + {new_state, new_map} + + {:ok, {:error, reason, updated_state}} -> + log_failed_task(pass_type, reason) + new_state = merge_calls_count(acc_state, updated_state) + {new_state, acc_map} + + {:error, reason} -> + Logger.error("[KillsPreloader] #{pass_type} fetch task crashed => #{inspect(reason)}") + {acc_state, acc_map} + end + end + + defp log_failed_task(:quick, reason), + do: Logger.warning("[KillsPreloader] Quick fetch task failed => #{inspect(reason)}") + + defp log_failed_task(:expanded, reason), + do: Logger.error("[KillsPreloader] Expanded fetch task failed => #{inspect(reason)}") + + defp broadcast_all_kills(kills_map, pass_type) do + Logger.info("[KillsPreloader] Broadcasting kills => #{map_size(kills_map)} systems (#{pass_type})") + + Phoenix.PubSub.broadcast!( + WandererApp.PubSub, + "zkb_preload", + %{ + event: :detailed_kills_updated, + payload: kills_map, + fetch_type: pass_type + } + ) + end + + defp merge_calls_count(%{calls_count: c1} = st1, %{calls_count: c2}), + do: %{st1 | calls_count: c1 + c2} + + defp merge_calls_count(st1, _other), + do: st1 + + defp pass_timeout_ms(:quick), do: :timer.minutes(2) + defp pass_timeout_ms(:expanded), do: :timer.minutes(5) + + defp measure_execution_time(fun) when is_function(fun, 0) do + start = System.monotonic_time() + result = fun.() + finish = System.monotonic_time() + ms = System.convert_time_unit(finish - start, :native, :millisecond) + {ms, result} + end +end diff --git a/lib/wanderer_app/zkb/zkb_kills_provider.ex b/lib/wanderer_app/zkb/zkb_kills_provider.ex index 2af342dc..de99a023 100644 --- a/lib/wanderer_app/zkb/zkb_kills_provider.ex +++ b/lib/wanderer_app/zkb/zkb_kills_provider.ex @@ -1,127 +1,29 @@ defmodule WandererApp.Zkb.KillsProvider do - @moduledoc false use Fresh + require Logger + + alias WandererApp.Zkb.KillsProvider.Websocket defstruct [:connected] - require Logger + def handle_connect(status, headers, state), + do: Websocket.handle_connect(status, headers, state) - @heartbeat_interval 1_000 + def handle_in(frame, state), + do: Websocket.handle_in(frame, state) - def handle_connect(_status, _headers, state) do - Logger.debug(fn -> - "#{__MODULE__}: connected to kills stream" - end) + def handle_control(msg, state), + do: Websocket.handle_control(msg, state) - handle_subscribe("killstream", %__MODULE__{state | connected: true}) - end + def handle_info(msg, state), + do: Websocket.handle_info(msg, state) - def handle_in({:text, frame}, state) do - frame - |> Jason.decode!() - |> handle_websocket(state) - end + def handle_disconnect(code, reason, state), + do: Websocket.handle_disconnect(code, reason, state) - def handle_control({:ping, _message}, state) do - Process.send_after(self(), :heartbeat, @heartbeat_interval) - {:ok, state} - end + def handle_error(err, state), + do: Websocket.handle_error(err, state) - def handle_control(_event, state) do - {:ok, state} - end - - def handle_info(:heartbeat, state) do - payload = - Jason.encode!(%{ - "action" => "pong" - }) - - {:reply, {:text, payload}, state} - end - - def handle_info(_message, state) do - {:ok, state} - end - - def handle_info(_message, _ws, state) do - {:ok, state} - end - - defp handle_subscribe(channel, state) do - Logger.debug(fn -> - "#{__MODULE__} subscribe: #{inspect(channel, pretty: true)}" - end) - - payload = - Jason.encode!(%{ - "action" => "sub", - "channel" => channel - }) - - {:reply, {:text, payload}, state} - end - - defp handle_websocket(message, state) do - case message |> parse_message() do - nil -> - {:ok, state} - - %{solar_system_id: solar_system_id, kill_time: kill_time} = _message -> - case DateTime.diff(DateTime.utc_now(), kill_time, :hour) do - 0 -> - WandererApp.Cache.incr("zkb_kills_#{solar_system_id}", 1, - default: 0, - ttl: :timer.hours(1) - ) - - _ -> - :ok - end - end - - {:ok, state} - end - - def handle_disconnect(1002, reason, _state) do - Logger.warning(fn -> - "Connection to socket lost by #{inspect(reason, pretty: true)}; reconnecting..." - end) - - :reconnect - end - - def handle_disconnect(_code, reason, _state) do - Logger.warning(fn -> - "Connection to socket lost by #{inspect(reason, pretty: true)}; closing..." - end) - - :reconnect - end - - def handle_error({error, _reason}, state) - when error in [:encoding_failed, :casting_failed], - do: {:ignore, state} - - def handle_error(_error, _state), do: :reconnect - - def handle_terminate(reason, _state) do - Logger.warning(fn -> "Terminating client process with reason : #{inspect(reason)}" end) - end - - defp parse_message( - %{ - "solar_system_id" => solar_system_id, - "killmail_time" => killmail_time - } = _message - ) do - {:ok, kill_time, _} = DateTime.from_iso8601(killmail_time) - - %{ - solar_system_id: solar_system_id, - kill_time: kill_time - } - end - - defp parse_message(_message), do: nil + def handle_terminate(reason, state), + do: Websocket.handle_terminate(reason, state) end diff --git a/lib/wanderer_app/zkb/zkb_supervisor.ex b/lib/wanderer_app/zkb/zkb_supervisor.ex index 77e548bf..67d705a1 100644 --- a/lib/wanderer_app/zkb/zkb_supervisor.ex +++ b/lib/wanderer_app/zkb/zkb_supervisor.ex @@ -8,15 +8,27 @@ defmodule WandererApp.Zkb.Supervisor do end def init(_init_args) do - children = [ - {WandererApp.Zkb.KillsProvider, - uri: "wss://zkillboard.com/websocket/", - state: %WandererApp.Zkb.KillsProvider{}, - opts: [ - name: {:local, :zkb_kills_provider}, - mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate] - ]} - ] + preloader_child = + unless WandererApp.Env.zkill_preload_disabled?() do + {WandererApp.Zkb.KillsPreloader, []} + end + + children = + [ + { + WandererApp.Zkb.KillsProvider, + uri: "wss://zkillboard.com/websocket/", + state: %WandererApp.Zkb.KillsProvider{ + connected: false + }, + opts: [ + name: {:local, :zkb_kills_provider}, + mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate] + ] + }, + preloader_child + ] + |> Enum.reject(&is_nil/1) Supervisor.init(children, strategy: :one_for_one) end diff --git a/lib/wanderer_app/zkb/zkills_provider/cache.ex b/lib/wanderer_app/zkb/zkills_provider/cache.ex new file mode 100644 index 00000000..d63c03e5 --- /dev/null +++ b/lib/wanderer_app/zkb/zkills_provider/cache.ex @@ -0,0 +1,165 @@ +defmodule WandererApp.Zkb.KillsProvider.KillsCache do + @moduledoc """ + Provides helper functions for putting/fetching kill data + """ + + require Logger + alias WandererApp.Cache + + @killmail_ttl :timer.hours(24) + @system_kills_ttl :timer.hours(1) + + # Base (average) expiry of 15 minutes for "recently fetched" systems + @base_full_fetch_expiry_ms 900_000 + @jitter_percent 0.1 + + def killmail_ttl, do: @killmail_ttl + def system_kills_ttl, do: @system_kills_ttl + + @doc """ + Store the killmail data, keyed by killmail_id, with a 24h TTL. + """ + def put_killmail(killmail_id, kill_data) do + Logger.debug("[KillsCache] Storing killmail => killmail_id=#{killmail_id}") + Cache.put(killmail_key(killmail_id), kill_data, ttl: @killmail_ttl) + end + + @doc """ + Fetch kills for `system_id` from the local cache only. + Returns a list of killmail maps (could be empty). + """ + def fetch_cached_kills(system_id) do + killmail_ids = get_system_killmail_ids(system_id) + # Debug-level log for performance checks + Logger.debug("[KillsCache] fetch_cached_kills => system_id=#{system_id}, count=#{length(killmail_ids)}") + + killmail_ids + |> Enum.map(&get_killmail/1) + |> Enum.reject(&is_nil/1) + end + + @doc """ + Fetch cached kills for multiple solar system IDs. + Returns a map of `%{ solar_system_id => list_of_kills }`. + """ + def fetch_cached_kills_for_systems(system_ids) when is_list(system_ids) do + Enum.reduce(system_ids, %{}, fn sid, acc -> + kills_list = fetch_cached_kills(sid) + Map.put(acc, sid, kills_list) + end) + end + + @doc """ + Fetch the killmail data (if any) from the cache, by killmail_id. + """ + def get_killmail(killmail_id) do + Cache.get(killmail_key(killmail_id)) + end + + @doc """ + Adds `killmail_id` to the list of killmail IDs for the system + if it’s not already present. The TTL is 24 hours. + """ + def add_killmail_id_to_system_list(solar_system_id, killmail_id) do + Cache.update( + system_kills_list_key(solar_system_id), + [], + fn existing_list -> + existing_list = existing_list || [] + if killmail_id in existing_list do + existing_list + else + existing_list ++ [killmail_id] + end + end, + ttl: @killmail_ttl + ) + end + + @doc """ + Returns a list of killmail IDs for the given system, or [] if none. + """ + def get_system_killmail_ids(solar_system_id) do + Cache.get(system_kills_list_key(solar_system_id)) || [] + end + + @doc """ + Increments the kill count for a system by `amount`. The TTL is 1 hour. + """ + def incr_system_kill_count(solar_system_id, amount \\ 1) do + Cache.incr( + system_kills_key(solar_system_id), + amount, + default: 0, + ttl: @system_kills_ttl + ) + end + + @doc """ + Returns the integer count of kills for this system in the last hour, or 0. + """ + def get_system_kill_count(solar_system_id) do + Cache.get(system_kills_key(solar_system_id)) || 0 + end + + @doc """ + Check if the system is still in its "recently fetched" window. + We store an `expires_at` timestamp (in ms). If `now < expires_at`, + this system is still considered "recently fetched". + """ + def recently_fetched?(system_id) do + case Cache.lookup(fetched_timestamp_key(system_id)) do + {:ok, expires_at_ms} when is_integer(expires_at_ms) -> + now_ms = current_time_ms() + now_ms < expires_at_ms + + _ -> + false + end + end + + @doc """ + Puts a jittered `expires_at` in the cache for `system_id`, + marking it as fully fetched for ~15 minutes (+/- 10%). + """ + def put_full_fetched_timestamp(system_id) do + now_ms = current_time_ms() + max_jitter = round(@base_full_fetch_expiry_ms * @jitter_percent) + # random offset in range [-max_jitter..+max_jitter] + offset = :rand.uniform(2 * max_jitter + 1) - (max_jitter + 1) + final_expiry_ms = max(@base_full_fetch_expiry_ms + offset, 60_000) + expires_at_ms = now_ms + final_expiry_ms + + Logger.debug("[KillsCache] Marking system=#{system_id} recently_fetched? until #{expires_at_ms} (ms)") + Cache.put(fetched_timestamp_key(system_id), expires_at_ms) + end + + @doc """ + Returns how many ms remain until this system's "recently fetched" window ends. + If it's already expired (or doesn't exist), returns -1. + """ + def fetch_age_ms(system_id) do + now_ms = current_time_ms() + + case Cache.lookup(fetched_timestamp_key(system_id)) do + {:ok, expires_at_ms} when is_integer(expires_at_ms) -> + if now_ms < expires_at_ms do + expires_at_ms - now_ms + else + -1 + end + + _ -> + -1 + end + end + + defp killmail_key(killmail_id), do: "zkb_killmail_#{killmail_id}" + defp system_kills_key(solar_system_id), do: "zkb_kills_#{solar_system_id}" + defp system_kills_list_key(solar_system_id), do: "zkb_kills_list_#{solar_system_id}" + defp fetched_timestamp_key(system_id), do: "zkb_system_fetched_at_#{system_id}" + + defp current_time_ms() do + DateTime.utc_now() |> DateTime.to_unix(:millisecond) + end +end diff --git a/lib/wanderer_app/zkb/zkills_provider/fetcher.ex b/lib/wanderer_app/zkb/zkills_provider/fetcher.ex new file mode 100644 index 00000000..644017fe --- /dev/null +++ b/lib/wanderer_app/zkb/zkills_provider/fetcher.ex @@ -0,0 +1,211 @@ +defmodule WandererApp.Zkb.KillsProvider.Fetcher do + @moduledoc """ + Low-level API for fetching killmails from zKillboard + ESI. + """ + + require Logger + use Retry + + alias WandererApp.Zkb.KillsProvider.{Parser, KillsCache, ZkbApi} + + @page_size 200 + @max_pages 2 + + @doc """ + Fetch killmails for multiple systems, returning a map of system_id => kills. + """ + def fetch_kills_for_systems(system_ids, since_hours, state, _opts \\ []) when is_list(system_ids) do + try do + {final_map, final_state} = + Enum.reduce(system_ids, {%{}, state}, fn sid, {acc_map, acc_st} -> + case fetch_kills_for_system(sid, since_hours, acc_st) do + {:ok, kills, new_st} -> + {Map.put(acc_map, sid, kills), new_st} + + {:error, reason, new_st} -> + Logger.debug("[Fetcher] system=#{sid} => error=#{inspect(reason)}") + {Map.put(acc_map, sid, {:error, reason}), new_st} + end + end) + + Logger.debug("[Fetcher] fetch_kills_for_systems => done, final_map_size=#{map_size(final_map)} calls=#{final_state.calls_count}") + {:ok, final_map} + rescue + e -> + Logger.error("[Fetcher] EXCEPTION in fetch_kills_for_systems => #{Exception.message(e)}") + {:error, e} + end + end + + @doc """ + Fetch killmails for a single system within `since_hours` cutoff. + + Options: + - `:limit` => integer limit on how many kills to fetch (optional). + If `limit` is nil (or not set), we fetch until we exhaust pages or older kills. + - `:force` => if true, ignore the "recently fetched" check and forcibly refetch. + + Returns `{:ok, kills, updated_state}` on success, or `{:error, reason, updated_state}`. + """ + def fetch_kills_for_system(system_id, since_hours, state, opts \\ []) do + limit = Keyword.get(opts, :limit, nil) + force? = Keyword.get(opts, :force, false) + + log_prefix = "[Fetcher] fetch_kills_for_system => system=#{system_id}" + + # Check the "recently fetched" cache if not forced + if not force? and KillsCache.recently_fetched?(system_id) do + cached_kills = KillsCache.fetch_cached_kills(system_id) + final = maybe_take(cached_kills, limit) + Logger.debug("#{log_prefix}, recently_fetched?=true => returning #{length(final)} cached kills") + {:ok, final, state} + else + Logger.debug("#{log_prefix}, hours=#{since_hours}, limit=#{inspect(limit)}, force=#{force?}") + + cutoff_dt = hours_ago(since_hours) + + result = + retry with: exponential_backoff(300) + |> randomize() + |> cap(5_000) + |> expiry(120_000) do + case do_multi_page_fetch(system_id, cutoff_dt, 1, 0, limit, state) do + {:ok, new_st, total_fetched} -> + # Mark system as fully fetched (to prevent repeated calls). + KillsCache.put_full_fetched_timestamp(system_id) + final_kills = KillsCache.fetch_cached_kills(system_id) |> maybe_take(limit) + + Logger.debug( + "#{log_prefix}, total_fetched=#{total_fetched}, final_cached=#{length(final_kills)}, calls_count=#{new_st.calls_count}" + ) + + {:ok, final_kills, new_st} + + {:error, :rate_limited, _new_st} -> + raise ":rate_limited" + + {:error, reason, _new_st} -> + raise "#{log_prefix}, reason=#{inspect(reason)}" + end + end + + case result do + {:ok, kills, new_st} -> + {:ok, kills, new_st} + + error -> + Logger.error("#{log_prefix}, EXHAUSTED => error=#{inspect(error)}") + {:error, error, state} + end + end + rescue + e -> + Logger.error("[Fetcher] EXCEPTION in fetch_kills_for_system => #{Exception.message(e)}") + {:error, e, state} + end + + defp do_multi_page_fetch(_system_id, _cutoff_dt, page, total_so_far, _limit, state) + when page > @max_pages do + # No more pages + {:ok, state, total_so_far} + end + + defp do_multi_page_fetch(system_id, cutoff_dt, page, total_so_far, limit, state) do + Logger.debug( + "[Fetcher] do_multi_page_fetch => system=#{system_id}, page=#{page}, total_so_far=#{total_so_far}, limit=#{inspect(limit)}" + ) + + with {:ok, st1} <- increment_calls_count(state), + {:ok, st2, partials} <- ZkbApi.fetch_and_parse_page(system_id, page, st1) do + Logger.debug("[Fetcher] system=#{system_id}, page=#{page}, partials_count=#{length(partials)}") + + {_count_stored, older_found?, total_now} = + Enum.reduce_while(partials, {0, false, total_so_far}, fn partial, {acc_count, had_older, acc_total} -> + # If we have a limit and reached it, stop immediately + if reached_limit?(limit, acc_total) do + {:halt, {acc_count, had_older, acc_total}} + else + case parse_partial(partial, cutoff_dt) do + :older -> + # Found an older kill => we can halt the entire multi-page fetch + {:halt, {acc_count, true, acc_total}} + + :ok -> + {:cont, {acc_count + 1, false, acc_total + 1}} + + :skip -> + {:cont, {acc_count, had_older, acc_total}} + end + end + end) + + cond do + # If we found older kills, stop now + older_found? -> + {:ok, st2, total_now} + + # If we have a limit and just reached or exceeded it + reached_limit?(limit, total_now) -> + {:ok, st2, total_now} + + # If partials < @page_size, no more kills are left + length(partials) < @page_size -> + {:ok, st2, total_now} + + # Otherwise, keep going to next page + true -> + do_multi_page_fetch(system_id, cutoff_dt, page + 1, total_now, limit, st2) + end + else + {:error, :rate_limited, stx} -> + {:error, :rate_limited, stx} + + {:error, reason, stx} -> + {:error, reason, stx} + + other -> + Logger.warning("[Fetcher] Unexpected result => #{inspect(other)}") + {:error, :unexpected, state} + end + end + + defp parse_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial, cutoff_dt) do + # If we've already cached this kill, skip + if KillsCache.get_killmail(kill_id) do + :skip + else + # Actually fetch the full kill from ESI + case fetch_full_killmail(kill_id, kill_hash) do + {:ok, full_km} -> + # Delegate the time check & storing to Parser + Parser.parse_full_and_store(full_km, partial, cutoff_dt) + + {:error, reason} -> + Logger.warning("[Fetcher] ESI fail => kill_id=#{kill_id}, reason=#{inspect(reason)}") + :skip + end + end + end + + defp parse_partial(_other, _cutoff_dt), do: :skip + + defp fetch_full_killmail(k_id, k_hash) do + case WandererApp.Esi.get_killmail(k_id, k_hash) do + {:ok, full_km} -> {:ok, full_km} + {:error, reason} -> {:error, reason} + end + end + + defp hours_ago(h), + do: DateTime.utc_now() |> DateTime.add(-h * 3600, :second) + + defp increment_calls_count(%{calls_count: c} = st), + do: {:ok, %{st | calls_count: c + 1}} + + defp reached_limit?(nil, _count_so_far), do: false + defp reached_limit?(limit, count_so_far) when is_integer(limit), + do: count_so_far >= limit + + defp maybe_take(kills, nil), do: kills + defp maybe_take(kills, limit), do: Enum.take(kills, limit) +end diff --git a/lib/wanderer_app/zkb/zkills_provider/parser.ex b/lib/wanderer_app/zkb/zkills_provider/parser.ex new file mode 100644 index 00000000..dc48d72d --- /dev/null +++ b/lib/wanderer_app/zkb/zkills_provider/parser.ex @@ -0,0 +1,321 @@ +defmodule WandererApp.Zkb.KillsProvider.Parser do + @moduledoc """ + Helper for parsing & storing a killmail from the ESI data (plus zKB partial). + Responsible for: + - Parsing the raw JSON structures, + - Combining partial & full kill data, + - Checking whether kills are 'too old', + - Storing in KillsCache, etc. + """ + + require Logger + alias WandererApp.Zkb.KillsProvider.KillsCache + + @doc """ + Merges the 'partial' from zKB and the 'full' killmail from ESI, checks its time + vs. `cutoff_dt`. + + Returns: + - `:ok` if we parsed & stored successfully, + - `:older` if killmail time is older than `cutoff_dt`, + - `:skip` if we cannot parse or store for some reason. + """ + def parse_full_and_store(full_km, partial_zkb, cutoff_dt) when is_map(full_km) do + # Attempt to parse the killmail_time + case parse_killmail_time(full_km) do + {:ok, km_dt} -> + if older_than_cutoff?(km_dt, cutoff_dt) do + :older + else + # Merge the "zkb" portion from the partial into the full killmail + enriched = Map.merge(full_km, %{"zkb" => partial_zkb["zkb"]}) + parse_and_store_killmail(enriched) + end + + _ -> + :skip + end + end + + def parse_full_and_store(_full_km, _partial_zkb, _cutoff_dt), + do: :skip + + @doc """ + Parse a raw killmail (`full_km`) and store it if valid. + Returns: + - `:ok` if successfully parsed & stored, + - `:skip` otherwise + """ + def parse_and_store_killmail(%{"killmail_id" => _kill_id} = full_km) do + parsed_map = do_parse(full_km) + + if is_nil(parsed_map) or is_nil(parsed_map["kill_time"]) do + :skip + else + store_killmail(parsed_map) + :ok + end + end + + def parse_and_store_killmail(_), + do: :skip + + defp do_parse(%{"killmail_id" => kill_id} = km) do + victim = Map.get(km, "victim", %{}) + attackers = Map.get(km, "attackers", []) + + kill_time_dt = + case DateTime.from_iso8601("#{Map.get(km, "killmail_time", "")}") do + {:ok, dt, _off} -> dt + _ -> nil + end + + npc_flag = get_in(km, ["zkb", "npc"]) || false + + %{ + "killmail_id" => kill_id, + "kill_time" => kill_time_dt, + "solar_system_id" => km["solar_system_id"], + "zkb" => Map.get(km, "zkb", %{}), + "attacker_count" => length(attackers), + "total_value" => get_in(km, ["zkb", "totalValue"]) || 0, + "victim" => victim, + "attackers" => attackers, + "npc" => npc_flag + } + end + + defp do_parse(_), + do: nil + + @doc """ + Extracts & returns {:ok, DateTime} from the "killmail_time" field, or :skip on failure. + """ + def parse_killmail_time(full_km) do + killmail_time_str = Map.get(full_km, "killmail_time", "") + + case DateTime.from_iso8601(killmail_time_str) do + {:ok, dt, _offset} -> + {:ok, dt} + + _ -> + :skip + end + end + + defp older_than_cutoff?(%DateTime{} = dt, %DateTime{} = cutoff_dt), + do: DateTime.compare(dt, cutoff_dt) == :lt + + defp store_killmail(%{"killmail_id" => nil}), do: :ok + + defp store_killmail(%{"killmail_id" => kill_id} = parsed) do + final = build_kill_data(parsed) + + if final do + enriched = maybe_enrich_killmail(final) + KillsCache.put_killmail(kill_id, enriched) + + system_id = enriched["solar_system_id"] + KillsCache.add_killmail_id_to_system_list(system_id, kill_id) + + if within_last_hour?(enriched["kill_time"]) do + KillsCache.incr_system_kill_count(system_id) + end + else + Logger.warning("[Parser] store_killmail => build_kill_data returned nil for kill_id=#{kill_id}") + end + end + + defp store_killmail(_), + do: :ok + + defp build_kill_data(%{ + "killmail_id" => kill_id, + "kill_time" => kill_time_dt, + "solar_system_id" => sys_id, + "zkb" => zkb, + "victim" => victim, + "attackers" => attackers, + "attacker_count" => attacker_count, + "total_value" => total_value, + "npc" => npc + }) do + + victim_map = extract_victim_fields(victim) + final_blow_map = extract_final_blow_fields(attackers) + + %{ + "killmail_id" => kill_id, + "kill_time" => kill_time_dt, + "solar_system_id" => sys_id, + "zkb" => zkb, + + "victim_char_id" => victim_map.char_id, + "victim_corp_id" => victim_map.corp_id, + "victim_alliance_id" => victim_map.alliance_id, + "victim_ship_type_id" => victim_map.ship_type_id, + + "final_blow_char_id" => final_blow_map.char_id, + "final_blow_corp_id" => final_blow_map.corp_id, + "final_blow_alliance_id" => final_blow_map.alliance_id, + "final_blow_ship_type_id" => final_blow_map.ship_type_id, + + "attacker_count" => attacker_count, + "total_value" => total_value, + "npc" => npc + } + end + + defp build_kill_data(_), + do: nil + + defp extract_victim_fields(%{ + "character_id" => cid, + "corporation_id" => corp, + "alliance_id" => alli, + "ship_type_id" => st_id + }), + do: %{char_id: cid, corp_id: corp, alliance_id: alli, ship_type_id: st_id} + + defp extract_victim_fields(%{ + "character_id" => cid, + "corporation_id" => corp, + "ship_type_id" => st_id + }), + do: %{char_id: cid, corp_id: corp, alliance_id: nil, ship_type_id: st_id} + + defp extract_victim_fields(_), + do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil} + + defp extract_final_blow_fields(attackers) when is_list(attackers) do + final = Enum.find(attackers, fn a -> a["final_blow"] == true end) + extract_attacker_fields(final) + end + + defp extract_final_blow_fields(_), + do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil} + + defp extract_attacker_fields(nil), + do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil} + + defp extract_attacker_fields(%{ + "character_id" => cid, + "corporation_id" => corp, + "alliance_id" => alli, + "ship_type_id" => st_id + }), + do: %{char_id: cid, corp_id: corp, alliance_id: alli, ship_type_id: st_id} + + defp extract_attacker_fields(%{ + "character_id" => cid, + "corporation_id" => corp, + "ship_type_id" => st_id + }), + do: %{char_id: cid, corp_id: corp, alliance_id: nil, ship_type_id: st_id} + + defp extract_attacker_fields(%{"ship_type_id" => st_id} = attacker) do + %{ + char_id: Map.get(attacker, "character_id"), + corp_id: Map.get(attacker, "corporation_id"), + alliance_id: Map.get(attacker, "alliance_id"), + ship_type_id: st_id + } + end + + defp extract_attacker_fields(_), + do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil} + + defp maybe_enrich_killmail(km) do + km + |> enrich_victim() + |> enrich_final_blow() + end + + defp enrich_victim(km) do + km + |> maybe_put_character_name("victim_char_id", "victim_char_name") + |> maybe_put_corp_ticker("victim_corp_id", "victim_corp_ticker") + |> maybe_put_alliance_ticker("victim_alliance_id", "victim_alliance_ticker") + |> maybe_put_ship_name("victim_ship_type_id", "victim_ship_name") + end + + defp enrich_final_blow(km) do + km + |> maybe_put_character_name("final_blow_char_id", "final_blow_char_name") + |> maybe_put_corp_ticker("final_blow_corp_id", "final_blow_corp_ticker") + |> maybe_put_alliance_ticker("final_blow_alliance_id", "final_blow_alliance_ticker") + |> maybe_put_ship_name("final_blow_ship_type_id", "final_blow_ship_name") + end + + defp maybe_put_character_name(km, id_key, name_key) do + case Map.get(km, id_key) do + nil -> km + 0 -> km + eve_id -> + case WandererApp.Esi.get_character_info(eve_id) do + {:ok, %{"name" => char_name}} -> + Map.put(km, name_key, char_name) + + _ -> + km + end + end + end + + defp maybe_put_corp_ticker(km, id_key, ticker_key) do + case Map.get(km, id_key) do + nil -> km + 0 -> km + corp_id -> + case WandererApp.Esi.get_corporation_info(corp_id) do + {:ok, %{"ticker" => ticker}} -> + Map.put(km, ticker_key, ticker) + + {:error, reason} -> + Logger.warning("[Parser] Failed to fetch corp info: ID=#{corp_id}, reason=#{inspect(reason)}") + km + + _ -> + km + end + end + end + + defp maybe_put_alliance_ticker(km, id_key, ticker_key) do + case Map.get(km, id_key) do + nil -> km + 0 -> km + alliance_id -> + case WandererApp.Esi.get_alliance_info(alliance_id) do + {:ok, %{"ticker" => alliance_ticker}} -> + Map.put(km, ticker_key, alliance_ticker) + + _ -> + km + end + end + end + + defp maybe_put_ship_name(km, id_key, name_key) do + case Map.get(km, id_key) do + nil -> km + 0 -> km + type_id -> + case WandererApp.CachedInfo.get_ship_type(type_id) do + {:ok, nil} -> km + {:ok, %{name: ship_name}} -> Map.put(km, name_key, ship_name) + {:error, reason} -> + Logger.warning("[Parser] Failed to fetch ship type: ID=#{type_id}, reason=#{inspect(reason)}") + km + + _ -> km + end + end + end + + # Utility + defp within_last_hour?(nil), do: false + + defp within_last_hour?(%DateTime{} = dt), + do: DateTime.diff(DateTime.utc_now(), dt, :minute) < 60 +end diff --git a/lib/wanderer_app/zkb/zkills_provider/websocket.ex b/lib/wanderer_app/zkb/zkills_provider/websocket.ex new file mode 100644 index 00000000..393b206e --- /dev/null +++ b/lib/wanderer_app/zkb/zkills_provider/websocket.ex @@ -0,0 +1,86 @@ +defmodule WandererApp.Zkb.KillsProvider.Websocket do + @moduledoc """ + Handles real-time kills from zKillboard WebSocket. + Always fetches from ESI to get killmail_time, victim, attackers, etc. + """ + + require Logger + alias WandererApp.Zkb.KillsProvider.Parser + alias WandererApp.Esi + + @heartbeat_interval 1_000 + + # Called by `KillsProvider.handle_connect` + def handle_connect(_status, _headers, %{connected: _} = state) do + Logger.info("[KillsProvider.Websocket] Connected => killstream") + new_state = Map.put(state, :connected, true) + handle_subscribe("killstream", new_state) + end + + # Called by `KillsProvider.handle_in` + def handle_in({:text, frame}, state) do + Logger.debug("[KillsProvider.Websocket] Received frame => #{frame}") + partial = Jason.decode!(frame) + parse_and_store_zkb_partial(partial) + {:ok, state} + end + + # Called for control frames + def handle_control({:pong, _msg}, state), + do: {:ok, state} + + def handle_control({:ping, _}, state) do + Process.send_after(self(), :heartbeat, @heartbeat_interval) + {:ok, state} + end + + # Called by the process mailbox + def handle_info(:heartbeat, state) do + payload = Jason.encode!(%{"action" => "pong"}) + {:reply, {:text, payload}, state} + end + + def handle_info(_other, state), do: {:ok, state} + + # Called on disconnect + def handle_disconnect(code, reason, _old_state) do + Logger.warning("[KillsProvider.Websocket] Disconnected => code=#{code}, reason=#{inspect(reason)} => reconnecting") + :reconnect + end + + # Called on errors + def handle_error({err, _reason}, state) when err in [:encoding_failed, :casting_failed], + do: {:ignore, state} + + def handle_error(_error, _state), + do: :reconnect + + # Called on terminate + def handle_terminate(reason, _state) do + Logger.warning("[KillsProvider.Websocket] Terminating => #{inspect(reason)}") + end + + defp handle_subscribe(channel, state) do + Logger.debug("[KillsProvider.Websocket] Subscribing to #{channel}") + payload = Jason.encode!(%{"action" => "sub", "channel" => channel}) + {:reply, {:text, payload}, state} + end + + # The partial from zKillboard has killmail_id + zkb.hash, but no time/victim/attackers + defp parse_and_store_zkb_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial) do + Logger.debug("[KillsProvider.Websocket] parse_and_store_zkb_partial => kill_id=#{kill_id}") + case Esi.get_killmail(kill_id, kill_hash) do + {:ok, full_esi_data} -> + # Merge partial zKB fields (like totalValue) onto ESI data + enriched = Map.merge(full_esi_data, %{"zkb" => partial["zkb"]}) + Parser.parse_and_store_killmail(enriched) + + {:error, reason} -> + Logger.warning("[KillsProvider.Websocket] ESI get_killmail failed => kill_id=#{kill_id}, reason=#{inspect(reason)}") + :skip + end + end + + defp parse_and_store_zkb_partial(_), + do: :skip +end diff --git a/lib/wanderer_app/zkb/zkills_provider/zkb_api.ex b/lib/wanderer_app/zkb/zkills_provider/zkb_api.ex new file mode 100644 index 00000000..7814b6b8 --- /dev/null +++ b/lib/wanderer_app/zkb/zkills_provider/zkb_api.ex @@ -0,0 +1,79 @@ +defmodule WandererApp.Zkb.KillsProvider.ZkbApi do + @moduledoc """ + A small module for making HTTP requests to zKillboard and + parsing JSON responses, separate from the multi-page logic. + """ + + require Logger + alias ExRated + + # 5 calls per second allowed + @exrated_bucket :zkb_preloader_provider + @exrated_interval_ms 1_000 + @exrated_max_requests 5 + + @zkillboard_api "https://zkillboard.com/api" + + @doc """ + Perform rate-limit check before fetching a single page from zKillboard and parse the response. + + Returns: + - `{:ok, updated_state, partials_list}` on success + - `{:error, reason, updated_state}` if error + """ + def fetch_and_parse_page(system_id, page, %{calls_count: _} = state) do + with :ok <- check_rate(), + {:ok, resp} <- do_req_get(system_id, page), + partials when is_list(partials) <- parse_response_body(resp) do + {:ok, state, partials} + else + {:error, :rate_limited} -> + {:error, :rate_limited, state} + + {:error, reason} -> + {:error, reason, state} + + _other -> + {:error, :unexpected, state} + end + end + + defp do_req_get(system_id, page) do + url = "#{@zkillboard_api}/kills/systemID/#{system_id}/page/#{page}/" + Logger.debug("[ZkbApi] GET => system=#{system_id}, page=#{page}, url=#{url}") + + try do + resp = Req.get!(url, decode_body: :json) + + if resp.status == 200 do + {:ok, resp} + else + {:error, {:http_status, resp.status}} + end + rescue + e -> + Logger.error(""" + [ZkbApi] do_req_get => exception: #{Exception.message(e)} + #{Exception.format_stacktrace(__STACKTRACE__)} + """) + {:error, :exception} + end + end + + defp parse_response_body(%{status: 200, body: body}) when is_list(body), + do: body + + defp parse_response_body(_), + do: :not_list + + defp check_rate do + case ExRated.check_rate(@exrated_bucket, @exrated_interval_ms, @exrated_max_requests) do + {:ok, _count} -> + :ok + + {:error, limit} -> + Logger.debug("[ZkbApi] RATE_LIMIT => limit=#{inspect(limit)}") + {:error, :rate_limited} + end + end +end diff --git a/lib/wanderer_app_web/live/maps/event_handlers/map_kills_event_handler.ex b/lib/wanderer_app_web/live/maps/event_handlers/map_kills_event_handler.ex new file mode 100644 index 00000000..2d3c5edd --- /dev/null +++ b/lib/wanderer_app_web/live/maps/event_handlers/map_kills_event_handler.ex @@ -0,0 +1,177 @@ +defmodule WandererAppWeb.MapKillsEventHandler do + @moduledoc """ + Handles kills-related UI/server events. + """ + + use WandererAppWeb, :live_component + require Logger + + alias WandererAppWeb.MapCoreEventHandler + alias WandererApp.Zkb.KillsProvider + alias WandererApp.Zkb.KillsProvider.KillsCache + + + def handle_server_event(%{event: :detailed_kills_updated, payload: payload}, socket) do + Phoenix.LiveView.push_event(socket, "detailed_kills_updated", payload) + end + + def handle_server_event(%{event: :fetch_system_kills_error, payload: {system_id, reason}}, socket) do + Logger.warning("[#{__MODULE__}] fetch_kills_for_system failed for sid=#{system_id}: #{inspect(reason)}") + socket + end + + def handle_server_event(%{event: :fetch_map_kills_error, payload: {map_id, reason}}, socket) do + Logger.warning("[#{__MODULE__}] fetch_kills_for_map failed for map=#{map_id}: #{inspect(reason)}") + socket + end + + def handle_server_event(%{event: :systems_kills_error, payload: {system_ids, reason}}, socket) do + Logger.warning("[#{__MODULE__}] fetch_kills_for_systems => error=#{inspect(reason)}, systems=#{inspect(system_ids)}") + socket + end + + def handle_server_event(%{event: :system_kills_error, payload: {system_id, reason}}, socket) do + Logger.warning("[#{__MODULE__}] fetch_kills_for_system => error=#{inspect(reason)} for system=#{system_id}") + socket + end + + def handle_server_event(event, socket) do + updated_socket = + case MapCoreEventHandler.handle_server_event(event, socket) do + {:noreply, new_socket} -> + new_socket + + {:reply, _payload, new_socket} -> + new_socket + + new_socket when is_map(new_socket) -> + new_socket + end + + updated_socket + end + + + def handle_server_event(%{event: :fetch_new_system_kills, payload: system}, socket) do + solar_system_id = system.solar_system_id + + Task.async(fn -> + case KillsProvider.Fetcher.fetch_kills_for_system(solar_system_id, 24, %{calls_count: 0}) do + {:ok, kills, _state} -> + {:detailed_kills_updated, %{solar_system_id => kills}} + + {:error, reason, _state} -> + Logger.warning("[#{__MODULE__}] Failed to fetch kills for system=#{solar_system_id}: #{inspect(reason)}") + {:fetch_system_kills_error, {solar_system_id, reason}} + end + end) + + socket + end + + def handle_server_event(%{event: :fetch_new_map_kills, payload: %{map_id: map_id}}, socket) do + Task.async(fn -> + with {:ok, map_systems} <- WandererApp.MapSystemRepo.get_visible_by_map(map_id), + system_ids <- Enum.map(map_systems, & &1.solar_system_id), + {:ok, systems_map} <- KillsProvider.Fetcher.fetch_kills_for_systems(system_ids, 24, %{calls_count: 0}) do + {:detailed_kills_updated, systems_map} + else + {:error, reason} -> + Logger.warning("[#{__MODULE__}] Failed to fetch kills for map=#{map_id}, reason=#{inspect(reason)}") + {:fetch_map_kills_error, {map_id, reason}} + end + end) + + socket + end + + def handle_server_event(event, socket), + do: MapCoreEventHandler.handle_server_event(event, socket) + + + def handle_ui_event("get_system_kills", %{"system_id" => sid, "since_hours" => sh} = payload, socket) do + with {:ok, system_id} <- parse_id(sid), + {:ok, since_hours} <- parse_id(sh) do + kills_from_cache = KillsCache.fetch_cached_kills(system_id) + reply_payload = %{"system_id" => system_id, "kills" => kills_from_cache} + + Task.async(fn -> + case KillsProvider.Fetcher.fetch_kills_for_system(system_id, since_hours, %{calls_count: 0}) do + {:ok, fresh_kills, _new_state} -> + {:detailed_kills_updated, %{system_id => fresh_kills}} + + {:error, reason, _new_state} -> + Logger.warning("[#{__MODULE__}] fetch_kills_for_system => error=#{inspect(reason)}") + {:system_kills_error, {system_id, reason}} + end + end) + + {:reply, reply_payload, socket} + else + :error -> + Logger.warning("[#{__MODULE__}] Invalid input to get_system_kills: #{inspect(payload)}") + {:reply, %{"error" => "invalid_input"}, socket} + end + end + + def handle_ui_event("get_systems_kills", %{"system_ids" => sids, "since_hours" => sh} = payload, socket) do + with {:ok, since_hours} <- parse_id(sh), + {:ok, parsed_ids} <- parse_system_ids(sids) do + cached_map = + Enum.reduce(parsed_ids, %{}, fn sid, acc -> + kills_list = KillsCache.fetch_cached_kills(sid) + Map.put(acc, sid, kills_list) + end) + + reply_payload = %{"systems_kills" => cached_map} + + Task.async(fn -> + case KillsProvider.Fetcher.fetch_kills_for_systems(parsed_ids, since_hours, %{calls_count: 0}) do + {:ok, systems_map} -> + {:detailed_kills_updated, systems_map} + + {:error, reason} -> + Logger.warning("[#{__MODULE__}] fetch_kills_for_systems => error=#{inspect(reason)}") + {:systems_kills_error, {parsed_ids, reason}} + end + end) + + {:reply, reply_payload, socket} + else + :error -> + Logger.warning("[#{__MODULE__}] Invalid multiple-systems input: #{inspect(payload)}") + {:reply, %{"error" => "invalid_input"}, socket} + end + end + + def handle_ui_event(event, payload, socket) do + MapCoreEventHandler.handle_ui_event(event, payload, socket) + end + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> :error + end + end + + defp parse_id(value) when is_integer(value), do: {:ok, value} + defp parse_id(_), do: :error + + defp parse_system_ids(ids) when is_list(ids) do + parsed = + Enum.reduce_while(ids, [], fn sid, acc -> + case parse_id(sid) do + {:ok, int_id} -> {:cont, [int_id | acc]} + :error -> {:halt, :error} + end + end) + + case parsed do + :error -> :error + list -> {:ok, Enum.reverse(list)} + end + end + + defp parse_system_ids(_), do: :error +end diff --git a/lib/wanderer_app_web/live/maps/event_handlers/map_structures_event_handler.ex b/lib/wanderer_app_web/live/maps/event_handlers/map_structures_event_handler.ex index 77bb8614..636cadfe 100644 --- a/lib/wanderer_app_web/live/maps/event_handlers/map_structures_event_handler.ex +++ b/lib/wanderer_app_web/live/maps/event_handlers/map_structures_event_handler.ex @@ -3,7 +3,6 @@ defmodule WandererAppWeb.MapStructuresEventHandler do use Phoenix.Component require Logger - alias WandererAppWeb.MapEventHandler alias WandererApp.Api.MapSystem alias WandererApp.Structure diff --git a/lib/wanderer_app_web/live/maps/map_event_handler.ex b/lib/wanderer_app_web/live/maps/map_event_handler.ex index 2de94b29..e3f5895c 100644 --- a/lib/wanderer_app_web/live/maps/map_event_handler.ex +++ b/lib/wanderer_app_web/live/maps/map_event_handler.ex @@ -12,6 +12,7 @@ defmodule WandererAppWeb.MapEventHandler do MapSignaturesEventHandler, MapSystemsEventHandler, MapStructuresEventHandler, + MapKillsEventHandler } @map_characters_events [ @@ -105,14 +106,25 @@ defmodule WandererAppWeb.MapEventHandler do ] @map_structures_events [ - :structures_updated, + :structures_updated ] @map_structures_ui_events [ "update_structures", "get_structures", "get_corporation_names", - "get_corporation_ticker", + "get_corporation_ticker" + ] + + @map_kills_events [ + :fetch_new_system_kills, + :detailed_kills_updated, + :fetch_new_map_kills + ] + + @map_kills_ui_events [ + "get_system_kills", + "get_systems_kills" ] def handle_event(socket, %{event: event_name} = event) @@ -136,13 +148,17 @@ defmodule WandererAppWeb.MapEventHandler do do: MapRoutesEventHandler.handle_server_event(event, socket) def handle_event(socket, %{event: event_name} = event) - when event_name in @map_structures_events, - do: MapSignaturesEventHandler.handle_server_event(event, socket) + when event_name in @map_structures_events, + do: MapSignaturesEventHandler.handle_server_event(event, socket) def handle_event(socket, %{event: event_name} = event) when event_name in @map_signatures_events, do: MapSignaturesEventHandler.handle_server_event(event, socket) + def handle_event(socket, %{event: event_name} = event) + when event_name in @map_kills_events, + do: MapKillsEventHandler.handle_server_event(event, socket) + def handle_event(socket, {ref, result}) when is_reference(ref) do Process.demonitor(ref, [:flush]) @@ -154,10 +170,7 @@ defmodule WandererAppWeb.MapEventHandler do {event, payload} -> Process.send_after( self(), - %{ - event: event, - payload: payload - }, + %{event: event, payload: payload}, 10 ) @@ -199,6 +212,10 @@ defmodule WandererAppWeb.MapEventHandler do when event in @map_activity_ui_events, do: MapActivityEventHandler.handle_ui_event(event, body, socket) + def handle_ui_event(event, body, socket) + when event in @map_kills_ui_events, + do: MapKillsEventHandler.handle_ui_event(event, body, socket) + def handle_ui_event(event, body, socket), do: MapCoreEventHandler.handle_ui_event(event, body, socket) diff --git a/mix.exs b/mix.exs index 18862ae6..b2bd9da5 100644 --- a/mix.exs +++ b/mix.exs @@ -34,7 +34,7 @@ defmodule WandererApp.MixProject do def application do [ mod: {WandererApp.Application, []}, - extra_applications: [:logger, :runtime_tools] + extra_applications: [:logger, :runtime_tools, :ex_rated] ] end @@ -54,6 +54,8 @@ defmodule WandererApp.MixProject do {:sobelow, ">= 0.0.0", only: [:dev], runtime: false}, {:mix_audit, ">= 0.0.0", only: [:dev], runtime: false}, {:ex_check, "~> 0.14.0", only: [:dev], runtime: false}, + {:ex_rated, "~> 2.0"}, + {:retry, "~> 0.18.0"}, {:phoenix, "~> 1.7.12"}, {:phoenix_ecto, "~> 4.6"}, {:ecto_sql, "~> 3.10"}, diff --git a/mix.lock b/mix.lock index fa1f89eb..609ca60e 100644 --- a/mix.lock +++ b/mix.lock @@ -37,6 +37,7 @@ "ex2ms": {:hex, :ex2ms, "1.7.0", "45b9f523d0b777667ded60070d82d871a37e294f0b6c5b8eca86771f00f82ee1", [:mix], [], "hexpm", "2589eee51f81f1b1caa6d08c990b1ad409215fe6f64c73f73c67d36ed10be827"}, "ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"}, "ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"}, + "ex_rated": {:hex, :ex_rated, "2.1.0", "d40e6fe35097b10222df2db7bb5dd801d57211bac65f29063de5f201c2a6aebc", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "936c155337253ed6474f06d941999dd3a9cf0fe767ec99a59f2d2989dc2cc13f"}, "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, "exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, @@ -102,6 +103,7 @@ "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "reactor": {:hex, :reactor, "0.10.0", "1206113c21ba69b889e072b2c189c05a7aced523b9c3cb8dbe2dab7062cb699a", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4003c33e4c8b10b38897badea395e404d74d59a31beb30469a220f2b1ffe6457"}, "req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"}, + "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"}, "rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"}, "site_encrypt": {:hex, :site_encrypt, "0.6.0", "9b3ae2b11723b9fa9b6fbee1d137ceaa0c245015a40c3f753a4ba1e8887986d2", [:mix], [{:bandit, "~> 0.7 or ~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}, {:mint, "~> 1.4", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:parent, "~> 0.11", [hex: :parent, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.8.8", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "16e77d0bec194b9e9d95ece4b7e5b072638e1c317d3dbe58d0c26ef3635a1e33"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"},