Compare commits

...

28 Commits

Author SHA1 Message Date
CI
ecd018abfe chore: release version v1.94.0 2026-02-08 12:03:11 +00:00
Dmitry Popov
f430f74e98 feat(administration): Added registered characters admin view with cort/ally info, sort and filter options 2026-02-08 13:02:35 +01:00
CI
9e146d1117 chore: [skip ci] 2026-02-08 09:08:10 +00:00
CI
0a707fb423 chore: release version v1.93.0 2026-02-08 09:08:10 +00:00
Dmitry Popov
8cda76cc43 feat(subscriptions): Added an ability to withdraw from map to user balance 2026-02-08 10:04:03 +01:00
CI
89d7df0ba2 chore: [skip ci] 2026-01-14 22:29:39 +00:00
CI
ba0c10d2e4 chore: release version v1.92.0 2026-01-14 22:29:39 +00:00
Dmitry Popov
996c88d839 Merge pull request #575 from wanderer-industries/k162-selector
K162 selector
2026-01-15 02:29:09 +04:00
Dmitry Popov
80e998cf79 fix(core): Show c1/c2/c3 or c4/c5 or link signature modal 2026-01-14 23:28:47 +01:00
Dmitry Popov
d2bcb89fa1 Merge branch 'main' into k162-selector 2026-01-13 20:27:48 +01:00
CI
922f296f17 chore: [skip ci] 2026-01-13 00:16:39 +00:00
CI
71dc20c933 chore: release version v1.91.11 2026-01-13 00:16:39 +00:00
Dmitry Popov
80f7d34d3d Merge pull request #573 from guarzo/guarzo/maprelayreturn
fix: allow sig api when map relay is off
2026-01-13 04:16:06 +04:00
Guarzo
113fe1c695 fix: allow sig api when map relay is off 2026-01-12 23:59:20 +00:00
DanSylvest
5550844912 feat: Added ability to select a range of wh classes for k162. 2026-01-12 12:39:53 +03:00
CI
0228e68a1d chore: [skip ci] 2026-01-07 12:35:19 +00:00
CI
3424667af1 chore: release version v1.91.10 2026-01-07 12:35:19 +00:00
Dmitry Popov
6c7b28a6c1 Merge pull request #571 from guarzo/guarzo/sigapi2
fix: remove actor context requirement from sig api
2026-01-07 16:34:34 +04:00
Guarzo
3988079cd3 fix: remove actor context requirement from sig api 2026-01-07 04:24:15 +00:00
CI
f5d407fee0 chore: [skip ci] 2026-01-06 15:38:03 +00:00
CI
a857422c46 chore: release version v1.91.9 2026-01-06 15:38:02 +00:00
Dmitry Popov
ec6717d0ef Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 16:37:32 +01:00
Dmitry Popov
56dacdcbbd fix(core): fixed rally point cancel logic 2026-01-06 16:37:29 +01:00
CI
c8e17b1691 chore: [skip ci] 2026-01-06 14:07:08 +00:00
CI
19c7fe59ee chore: release version v1.91.8 2026-01-06 14:07:08 +00:00
Dmitry Popov
682100c231 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 15:06:34 +01:00
Dmitry Popov
f9ac79cdcc fix(core): fixed rally point cancel logic 2026-01-06 15:06:31 +01:00
CI
f09f220645 chore: [skip ci] 2026-01-05 20:29:10 +00:00
27 changed files with 931 additions and 73 deletions

View File

@@ -2,6 +2,73 @@
<!-- changelog -->
## [v1.94.0](https://github.com/wanderer-industries/wanderer/compare/v1.93.0...v1.94.0) (2026-02-08)
### Features:
* administration: Added registered characters admin view with cort/ally info, sort and filter options
## [v1.93.0](https://github.com/wanderer-industries/wanderer/compare/v1.92.0...v1.93.0) (2026-02-08)
### Features:
* subscriptions: Added an ability to withdraw from map to user balance
## [v1.92.0](https://github.com/wanderer-industries/wanderer/compare/v1.91.11...v1.92.0) (2026-01-14)
### Features:
* Added ability to select a range of wh classes for k162.
### Bug Fixes:
* core: Show c1/c2/c3 or c4/c5 or link signature modal
## [v1.91.11](https://github.com/wanderer-industries/wanderer/compare/v1.91.10...v1.91.11) (2026-01-13)
### Bug Fixes:
* allow sig api when map relay is off
## [v1.91.10](https://github.com/wanderer-industries/wanderer/compare/v1.91.9...v1.91.10) (2026-01-07)
### Bug Fixes:
* remove actor context requirement from sig api
## [v1.91.9](https://github.com/wanderer-industries/wanderer/compare/v1.91.8...v1.91.9) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.8](https://github.com/wanderer-industries/wanderer/compare/v1.91.7...v1.91.8) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.7](https://github.com/wanderer-industries/wanderer/compare/v1.91.6...v1.91.7) (2026-01-05)

View File

@@ -72,7 +72,7 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
const {
storedSettings: { interfaceSettings },
data: { systemSignatures: mapSystemSignatures },
data: { systemSignatures: mapSystemSignatures, pings },
} = useMapRootState();
const systemStaticInfo = useMemo(() => {
@@ -108,7 +108,6 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
visibleNodes,
showKSpaceBG,
isThickConnections,
pings,
systemHighlighted,
},
outCommand,

View File

@@ -121,6 +121,7 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
useEffect(() => {
if (!ping) {
setIsShow(false);
return;
}
@@ -161,27 +162,26 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
};
}, [interfaceSettings]);
if (!ping) {
return null;
}
const isShowSelectedSystem = selectedSystem != null && selectedSystem !== ping.solar_system_id;
const isShowSelectedSystem = ping && selectedSystem != null && selectedSystem !== ping.solar_system_id;
// Only render Toast when there's a ping
return (
<>
<Toast
position={placement as never}
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
ref={toast}
content={({ message }) => (
<section
className={clsx(
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
)}
>
<div className="flex gap-3">
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
{ping && (
<Toast
key={ping.id}
position={placement as never}
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
ref={toast}
content={({ message }) => (
<section
className={clsx(
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
)}
>
<div className="flex gap-3">
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
<div className="flex flex-col gap-1 w-full">
<div className="flex justify-between">
<div>
@@ -253,28 +253,33 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
{/*/>*/}
</div>
</section>
)}
></Toast>
)}
></Toast>
)}
<WdButton
icon="pi pi-bell"
severity="warning"
aria-label="Notification"
size="small"
className="w-[33px] h-[33px]"
outlined
onClick={handleClickShow}
disabled={isShow}
/>
{ping && (
<>
<WdButton
icon="pi pi-bell"
severity="warning"
aria-label="Notification"
size="small"
className="w-[33px] h-[33px]"
outlined
onClick={handleClickShow}
disabled={isShow}
/>
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}
/>
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}
/>
</>
)}
</>
);
};

View File

@@ -3,9 +3,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSystemInfo } from '@/hooks/Mapper/components/hooks';
import {
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
} from '@/hooks/Mapper/components/map/constants.ts';
import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
@@ -91,7 +91,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
if (k162TypeInfo) {
// Check if the k162Type matches our target system class
return customInfo.k162Type === targetSystemClassGroup;
return k162TypeInfo.value.includes(targetSystemClassGroup);
}
}

View File

@@ -13,6 +13,26 @@ export const renderK162Type = (option: K162Type) => {
return renderNoValue();
}
if (['c1_c2_c3', 'c4_c5'].includes(value)) {
const arr = whClassName.split('_');
return (
<div className="flex gap-1 items-center">
{arr.map(x => (
<WHClassView
key={x}
classNameWh="!text-[11px] !font-bold"
hideWhClassName
hideTooltip
whClassName={x}
noOffset
useShortTitle
/>
))}
</div>
);
}
return (
<WHClassView
classNameWh="!text-[11px] !font-bold"

View File

@@ -88,6 +88,16 @@ export const K162_TYPES: K162Type[] = [
value: 'ns',
whClassName: 'C248',
},
{
label: 'C1/C2/C3',
value: 'c1_c2_c3',
whClassName: 'E004_D382_L477',
},
{
label: 'C4/C5',
value: 'c4_c5',
whClassName: 'M001_L614',
},
{
label: 'C1',
value: 'c1',

View File

@@ -10,3 +10,4 @@ export * from './useCommandComments';
export * from './useGetCacheCharacter';
export * from './useCommandsActivity';
export * from './useCommandPings';
export * from './useCommandPingBlocked';

View File

@@ -0,0 +1,21 @@
import { useToast } from '@/hooks/Mapper/ToastProvider';
import { CommandPingBlocked } from '@/hooks/Mapper/types';
import { useCallback } from 'react';
export const useCommandPingBlocked = () => {
const { show } = useToast();
const pingBlocked = useCallback(
({ message }: CommandPingBlocked) => {
show({
severity: 'warn',
summary: 'Cannot create ping',
detail: message,
life: 5000,
});
},
[show],
);
return { pingBlocked };
};

View File

@@ -14,8 +14,8 @@ export const useCommandPings = () => {
ref.current.update({ pings });
}, []);
const pingCancelled = useCallback(({ type, id }: CommandPingCancelled) => {
const newPings = ref.current.pings.filter(x => x.id !== id && x.type !== type);
const pingCancelled = useCallback(({ id }: CommandPingCancelled) => {
const newPings = ref.current.pings.filter(x => x.id !== id);
ref.current.update({ pings: newPings });
}, []);

View File

@@ -63,7 +63,6 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
const removeComment = useCallback((systemId: number, commentId: string) => {
const cSystem = commentBySystemsRef.current.get(systemId);
console.log('cSystem', cSystem);
if (!cSystem) {
return;
}

View File

@@ -12,6 +12,7 @@ import {
CommandLinkSignatureToSystem,
CommandMapUpdated,
CommandPingAdded,
CommandPingBlocked,
CommandPingCancelled,
CommandPresentCharacters,
CommandRemoveConnections,
@@ -29,6 +30,7 @@ import { ForwardedRef, useImperativeHandle } from 'react';
import {
useCommandComments,
useCommandPingBlocked,
useCommandPings,
useCommandsCharacters,
useCommandsConnections,
@@ -61,6 +63,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapUserRoutes = useUserRoutes();
const { addComment, removeComment } = useCommandComments();
const { pingAdded, pingCancelled } = useCommandPings();
const { pingBlocked } = useCommandPingBlocked();
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
useImperativeHandle(ref, () => {
@@ -172,6 +175,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
case Commands.pingBlocked:
pingBlocked(data as CommandPingBlocked);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;

View File

@@ -41,6 +41,7 @@ export enum Commands {
refreshTrackingData = 'refresh_tracking_data',
pingAdded = 'ping_added',
pingCancelled = 'ping_cancelled',
pingBlocked = 'ping_blocked',
}
export type Command =
@@ -77,7 +78,8 @@ export type Command =
| Commands.showTracking
| Commands.refreshTrackingData
| Commands.pingAdded
| Commands.pingCancelled;
| Commands.pingCancelled
| Commands.pingBlocked;
export type CommandInit = {
systems: SolarSystemRawType[];
@@ -161,6 +163,10 @@ export type CommandUpdateTracking = {
};
export type CommandPingAdded = PingData[];
export type CommandPingCancelled = Pick<PingData, 'type' | 'id'>;
export type CommandPingBlocked = {
reason: string;
message: string;
};
export interface UserSettings {
primaryCharacterId?: string;
@@ -212,6 +218,7 @@ export interface CommandData {
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
[Commands.pingAdded]: CommandPingAdded;
[Commands.pingCancelled]: CommandPingCancelled;
[Commands.pingBlocked]: CommandPingBlocked;
}
export interface MapHandlers {

View File

@@ -39,6 +39,8 @@ defmodule WandererApp.Api.Character do
define(:active_by_user,
action: :active_by_user
)
define(:admin_all, action: :admin_all)
end
actions do
@@ -69,6 +71,10 @@ defmodule WandererApp.Api.Character do
filter(expr(user_id == ^arg(:user_id) and deleted == false))
end
read :admin_all do
prepare build(load: [:user])
end
read :last_active do
argument(:from, :utc_datetime, allow_nil?: false)

View File

@@ -80,6 +80,10 @@ defmodule WandererApp.Api.MapPing do
filter(expr(inserted_at <= ^arg(:inserted_before)))
end
# Admin action for cleanup - no actor filtering
read :all_pings do
end
end
attributes do

View File

@@ -16,7 +16,7 @@ defmodule WandererApp.Map.Manager do
@maps_queue :maps_queue
@check_maps_queue_interval :timer.seconds(1)
@pings_cleanup_interval :timer.minutes(10)
@pings_cleanup_interval :timer.minutes(5)
@pings_expire_minutes 60
# Test-aware async task runner
@@ -99,6 +99,7 @@ defmodule WandererApp.Map.Manager do
def handle_info(:cleanup_pings, state) do
try do
cleanup_expired_pings()
cleanup_orphaned_pings()
{:noreply, state}
rescue
e ->
@@ -141,6 +142,51 @@ defmodule WandererApp.Map.Manager do
end
end
defp cleanup_orphaned_pings() do
case WandererApp.MapPingsRepo.get_orphaned_pings() do
{:ok, []} ->
:ok
{:ok, orphaned_pings} ->
Logger.info(
"[cleanup_orphaned_pings] Found #{length(orphaned_pings)} orphaned pings, cleaning up..."
)
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} = ping ->
reason =
cond do
is_nil(ping.system) -> "system deleted"
is_nil(ping.character) -> "character deleted"
is_nil(ping.map) -> "map deleted"
not is_nil(system) and system.visible == false -> "system hidden (visible=false)"
true -> "unknown"
end
Logger.warning(
"[cleanup_orphaned_pings] Destroying orphaned ping #{ping_id} (map_id: #{map_id}, reason: #{reason})"
)
# Broadcast cancellation if map_id is still valid
if map_id do
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
end
Ash.destroy!(ping)
end)
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
:ok
{:error, error} ->
Logger.error("Failed to fetch orphaned pings: #{inspect(error)}")
{:error, error}
end
end
defp start_maps() do
chunks =
@maps_queue

View File

@@ -78,7 +78,8 @@ defmodule WandererApp.Map.Operations.Signatures do
)
when is_integer(solar_system_id) do
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, system} <- MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id) do
{:ok, system} <-
MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: solar_system_id}) do
attrs =
params
|> Map.put("system_id", system.id)

View File

@@ -72,17 +72,24 @@ defmodule WandererApp.Map.Server.PingsImpl do
type: type
} = _ping_info
) do
case WandererApp.MapPingsRepo.get_by_id(ping_id) do
result = WandererApp.MapPingsRepo.get_by_id(ping_id)
case result do
{:ok,
%{system: %{id: system_id, name: system_name, solar_system_id: solar_system_id}} = ping} ->
with {:ok, character} <- WandererApp.Character.get_character(character_id),
:ok <- WandererApp.MapPingsRepo.destroy(ping) do
Logger.debug("Ping #{ping_id} destroyed successfully, broadcasting :ping_cancelled")
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: solar_system_id,
type: type
})
Logger.debug("Broadcast :ping_cancelled sent for ping #{ping_id}")
# Broadcast rally point removal events to external clients (webhooks/SSE)
if type == 1 do
WandererApp.ExternalEvents.broadcast(map_id, :rally_point_removed, %{
@@ -107,18 +114,45 @@ defmodule WandererApp.Map.Server.PingsImpl do
Logger.error("Failed to destroy ping: #{inspect(error, pretty: true)}")
end
# Handle case where ping exists but system was deleted (nil)
{:ok, %{system: nil} = ping} ->
case WandererApp.MapPingsRepo.destroy(ping) do
:ok ->
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
error ->
Logger.error("Failed to destroy orphaned ping: #{inspect(error, pretty: true)}")
end
{:error, %Ash.Error.Query.NotFound{}} ->
# Ping already deleted (possibly by cascade deletion from map/system/character removal,
# auto-expiry, or concurrent cancellation). This is not an error - the desired state
# (ping is gone) is already achieved. Just broadcast the cancellation event.
Logger.debug(
"Ping #{ping_id} not found during cancellation - already deleted, skipping broadcast"
)
# auto-expiry, or concurrent cancellation). Broadcast cancellation so frontend updates.
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
:ok
error ->
Logger.error("Failed to fetch ping for cancellation: #{inspect(error, pretty: true)}")
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
# Same as above, but Ash wraps NotFound inside Invalid in some cases
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
:ok
other ->
Logger.error(
"Failed to cancel ping #{ping_id}: unexpected result from get_by_id: #{inspect(other, pretty: true)}"
)
end
end
end

View File

@@ -167,6 +167,9 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
updated_count: length(updated_ids),
removed_count: length(removed_ids)
})
# Always return :ok - external event failures should not affect the main operation
:ok
end
defp remove_signature(map_id, sig, system, delete_conn?) do

View File

@@ -29,6 +29,34 @@ defmodule WandererApp.MapPingsRepo do
def get_by_inserted_before(inserted_before_date),
do: WandererApp.Api.MapPing.by_inserted_before(inserted_before_date)
@doc """
Returns all pings that have orphaned relationships (nil system, character, or map)
or where the system has been soft-deleted (visible = false).
These pings should be cleaned up as they can no longer be properly displayed or cancelled.
"""
def get_orphaned_pings() do
# Use :all_pings action which has no actor filtering (unlike primary :read)
case WandererApp.Api.MapPing |> Ash.Query.for_read(:all_pings) |> Ash.read() do
{:ok, pings} ->
# Load relationships and filter for orphaned ones
orphaned =
pings
|> Enum.map(fn ping ->
{:ok, loaded} = ping |> Ash.load([:system, :character, :map], authorize?: false)
loaded
end)
|> Enum.filter(fn ping ->
is_nil(ping.system) or is_nil(ping.character) or is_nil(ping.map) or
(not is_nil(ping.system) and ping.system.visible == false)
end)
{:ok, orphaned}
error ->
error
end
end
def create(ping), do: ping |> WandererApp.Api.MapPing.new()
def create!(ping), do: ping |> WandererApp.Api.MapPing.new!()
@@ -38,4 +66,24 @@ defmodule WandererApp.MapPingsRepo do
:ok
end
@doc """
Deletes all pings for a given map. Use with caution - for cleanup purposes.
"""
def delete_all_for_map(map_id) do
case get_by_map(map_id) do
{:ok, pings} ->
Logger.info("[MapPingsRepo] Deleting #{length(pings)} pings for map #{map_id}")
Enum.each(pings, fn ping ->
Logger.info("[MapPingsRepo] Deleting ping #{ping.id} (type: #{ping.type})")
Ash.destroy!(ping)
end)
{:ok, length(pings)}
error ->
error
end
end
end

View File

@@ -256,6 +256,11 @@ defmodule WandererAppWeb.Layouts do
Admin
</.link>
</li>
<li :if={@show_admin}>
<.link navigate="/admin/characters">
Characters
</.link>
</li>
<li :if={@show_admin}>
<.link navigate="/admin/errors">
Errors

View File

@@ -0,0 +1,157 @@
defmodule WandererAppWeb.AdminCharactersLive do
@moduledoc """
Admin LiveView for viewing all registered characters on the server.
"""
use WandererAppWeb, :live_view
alias Phoenix.LiveView.AsyncResult
@characters_per_page 50
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket)
when not is_nil(user_id) and is_connected?(socket) do
{:ok,
socket
|> assign(
characters: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @characters_per_page,
sort_by: :name,
sort_dir: :asc
)
|> load_characters_async()}
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(
characters: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @characters_per_page,
sort_by: :name,
sort_dir: :asc
)}
end
@impl true
def handle_params(params, _url, socket) when is_connected?(socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Characters")
end
defp load_characters_async(socket) do
socket
|> assign_async(:characters, fn -> load_all_characters() end)
end
defp load_all_characters do
case WandererApp.Api.Character.admin_all() do
{:ok, characters} ->
{:ok, %{characters: characters}}
_ ->
{:ok, %{characters: []}}
end
end
@impl true
def handle_event("search", %{"value" => term}, socket) do
{:noreply, socket |> assign(:search_term, term) |> assign(:page, 1)}
end
@impl true
def handle_event("toggle_deleted", _params, socket) do
{:noreply,
socket |> assign(:show_deleted, not socket.assigns.show_deleted) |> assign(:page, 1)}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field = String.to_existing_atom(field)
{sort_by, sort_dir} =
if socket.assigns.sort_by == field do
{field, toggle_dir(socket.assigns.sort_dir)}
else
{field, :asc}
end
{:noreply, socket |> assign(sort_by: sort_by, sort_dir: sort_dir, page: 1)}
end
@impl true
def handle_event("page", %{"page" => page}, socket) do
{:noreply, socket |> assign(:page, String.to_integer(page))}
end
@impl true
def handle_event(_event, _params, socket) do
{:noreply, socket}
end
def filter_characters(characters, search_term, show_deleted) do
characters
|> Enum.filter(fn char ->
(show_deleted or not char.deleted) and
(search_term == "" or
String.contains?(String.downcase(char.name || ""), String.downcase(search_term)) or
String.contains?(
String.downcase(char.corporation_name || ""),
String.downcase(search_term)
) or
String.contains?(
String.downcase(char.alliance_name || ""),
String.downcase(search_term)
))
end)
end
def sort_characters(characters, sort_by, sort_dir) do
Enum.sort_by(characters, &sort_value(&1, sort_by), sort_dir)
end
defp sort_value(char, :name), do: String.downcase(char.name || "")
defp sort_value(char, :corporation), do: String.downcase(char.corporation_name || "")
defp sort_value(char, :alliance), do: String.downcase(char.alliance_name || "")
defp sort_value(char, :user), do: String.downcase(user_name(char.user))
defp sort_value(char, :registered), do: char.inserted_at || ~U[1970-01-01 00:00:00Z]
defp toggle_dir(:asc), do: :desc
defp toggle_dir(:desc), do: :asc
def paginate(items, page, per_page) do
items
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
def total_pages(items, per_page) do
max(1, ceil(length(items) / per_page))
end
def format_date(nil), do: "-"
def format_date(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
def user_name(nil), do: "Unlinked"
def user_name(%{name: name}), do: name
end

View File

@@ -0,0 +1,166 @@
<main class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 overflow-auto">
<div class="page-content">
<div class="container-fluid px-[0.625rem]">
<!-- Header -->
<div class="grid grid-cols-1 pb-6">
<div class="md:flex items-center justify-between px-[2px]">
<h4 class="text-[18px] font-medium text-gray-800 mb-sm-0 grow dark:text-gray-100 mb-2 md:mb-0">
Admin - Characters
</h4>
<.link navigate={~p"/admin"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left-solid" class="w-4 h-4" /> Back to Admin
</.link>
</div>
</div>
<!-- Search and Filters -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600 mb-4">
<div class="card-body flex flex-row gap-4 items-center">
<div class="flex-1">
<input
type="text"
placeholder="Search by name, corporation, or alliance..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
name="search"
class="input input-bordered w-full"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox"
checked={@show_deleted}
phx-click="toggle_deleted"
/>
<span class="text-sm">Show deleted</span>
</label>
</div>
</div>
<!-- Characters Table -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.async_result :let={characters} assign={@characters}>
<:loading>
<div class="flex justify-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
</:loading>
<:failed :let={reason}>
<div class="alert alert-error">{inspect(reason)}</div>
</:failed>
<% filtered = filter_characters(characters, @search_term, @show_deleted) %>
<% sorted = sort_characters(filtered, @sort_by, @sort_dir) %>
<% paginated = paginate(sorted, @page, @per_page) %>
<div class="overflow-x-auto !max-h-[60vh] !overflow-y-auto">
<table class="table table-xs">
<thead>
<tr>
<th
:for={
{label, field} <- [
{"Character", :name},
{"Corporation", :corporation},
{"Alliance", :alliance},
{"User Account", :user},
{"Registered", :registered}
]
}
phx-click="sort"
phx-value-field={field}
class="cursor-pointer select-none hover:bg-base-200"
>
<div class="flex items-center gap-1">
{label}
<span :if={@sort_by == field}>
<.icon :if={@sort_dir == :asc} name="hero-chevron-up" class="w-3 h-3" />
<.icon
:if={@sort_dir == :desc}
name="hero-chevron-down"
class="w-3 h-3"
/>
</span>
</div>
</th>
</tr>
</thead>
<tbody id="admin-characters" phx-update="replace">
<tr :for={char <- paginated} id={"char-#{char.id}"}>
<td>
<div class="flex items-center gap-2">
<.avatar url={member_icon_url(char.eve_id)} label={char.name} />
<span class={if char.deleted, do: "line-through text-gray-500", else: ""}>
{char.name}
</span>
<span :if={char.deleted} class="badge badge-error badge-sm">
Deleted
</span>
<span :if={char.online} class="badge badge-success badge-sm">
Online
</span>
</div>
</td>
<td>
<span :if={char.corporation_name}>
{char.corporation_name}
<span :if={char.corporation_ticker} class="text-gray-400">
[{char.corporation_ticker}]
</span>
</span>
</td>
<td>
<span :if={char.alliance_name}>
{char.alliance_name}
<span :if={char.alliance_ticker} class="text-gray-400">
[{char.alliance_ticker}]
</span>
</span>
</td>
<td>{user_name(char.user)}</td>
<td>
<span class="text-sm">{format_date(char.inserted_at)}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div :if={length(filtered) > @per_page} class="flex items-center justify-between mt-4">
<span class="text-sm text-gray-400">
Page {@page} of {total_pages(filtered, @per_page)} ({length(filtered)} characters)
</span>
<div class="flex gap-2">
<button
phx-click="page"
phx-value-page={max(1, @page - 1)}
disabled={@page <= 1}
class={"btn btn-sm btn-ghost " <> if(@page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="page"
phx-value-page={min(total_pages(filtered, @per_page), @page + 1)}
disabled={@page >= total_pages(filtered, @per_page)}
class={"btn btn-sm btn-ghost " <> if(@page >= total_pages(filtered, @per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Empty state -->
<div :if={length(filtered) == 0} class="text-center py-8 text-gray-400">
No characters found
</div>
</.async_result>
</div>
</div>
</div>
</div>
</main>

View File

@@ -24,6 +24,18 @@
</.link>
</div>
</div>
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<span class="text-gray-400 dark:text-gray-400">Characters</span>
<.link
class="btn mt-2 w-full btn-neutral rounded-none"
navigate={~p"/admin/characters"}
>
<.icon name="hero-users-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">View All Characters</h3>
</.link>
</div>
</div>
<div :if={@restrict_maps_creation?} class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.button class="mt-2" type="button" phx-click="create-map">

View File

@@ -51,14 +51,18 @@ defmodule WandererAppWeb.MapPingsEventHandler do
map_ui_ping(ping_info)
])
def handle_server_event(%{event: :ping_cancelled, payload: ping_info}, socket),
do:
socket
|> MapEventHandler.push_map_event("ping_cancelled", %{
id: ping_info.id,
solar_system_id: ping_info.solar_system_id,
type: ping_info.type
})
def handle_server_event(%{event: :ping_cancelled, payload: ping_info}, socket) do
Logger.debug(
"handle_server_event :ping_cancelled - id: #{ping_info.id}, is_version_valid?: #{inspect(socket.assigns[:is_version_valid?])}"
)
socket
|> MapEventHandler.push_map_event("ping_cancelled", %{
id: ping_info.id,
solar_system_id: ping_info.solar_system_id,
type: ping_info.type
})
end
def handle_server_event(event, socket),
do: MapCoreEventHandler.handle_server_event(event, socket)
@@ -81,12 +85,41 @@ defmodule WandererAppWeb.MapPingsEventHandler do
when not is_nil(main_character_id) do
{:ok, pings} = WandererApp.MapPingsRepo.get_by_map(map_id)
no_exisiting_pings =
# Filter out orphaned pings (system/character deleted or system hidden)
# These should not block new ping creation
valid_pings =
pings
|> Enum.filter(fn ping ->
not is_nil(ping.system) and not is_nil(ping.character) and
(is_nil(ping.system.visible) or ping.system.visible == true)
end)
existing_rally_pings =
valid_pings
|> Enum.filter(fn %{type: type} ->
type == 1
end)
|> Enum.empty?()
no_exisiting_pings = Enum.empty?(existing_rally_pings)
orphaned_count = length(pings) - length(valid_pings)
# Log detailed info about existing pings for debugging
if length(existing_rally_pings) > 0 do
ping_details =
existing_rally_pings
|> Enum.map(fn p ->
"id=#{p.id}, type=#{p.type}, system_id=#{inspect(p.system_id)}, character_id=#{inspect(p.character_id)}, inserted_at=#{p.inserted_at}"
end)
|> Enum.join("; ")
Logger.warning(
"add_ping BLOCKED: map_id=#{map_id}, existing_rally_pings=#{length(existing_rally_pings)}: [#{ping_details}]"
)
else
Logger.debug(
"add_ping check: map_id=#{map_id}, total_pings=#{length(pings)}, valid_pings=#{length(valid_pings)}, orphaned=#{orphaned_count}, rally_pings=0, can_create=true"
)
end
if no_exisiting_pings do
map_id
@@ -97,9 +130,16 @@ defmodule WandererAppWeb.MapPingsEventHandler do
character_id: main_character_id,
user_id: current_user.id
})
end
{:noreply, socket}
{:noreply, socket}
else
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "rally_point_exists",
message: "A rally point already exists on this map"
})}
end
end
def handle_ui_event(
@@ -128,6 +168,80 @@ defmodule WandererAppWeb.MapPingsEventHandler do
{:noreply, socket}
end
# Catch add_ping when main_character_id is nil
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{main_character_id: nil}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_main_character",
message: "Please select a main character to create pings"
})}
end
# Catch add_ping when has_tracked_characters? is false
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{has_tracked_characters?: false}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_tracked_characters",
message: "Please add a tracked character to create pings"
})}
end
# Catch add_ping when subscription is not active
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{is_subscription_active?: false}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "subscription_inactive",
message: "Map subscription is not active"
})}
end
# Catch add_ping when user doesn't have update_system permission
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{user_permissions: %{update_system: false}}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_permission",
message: "You don't have permission to create pings on this map"
})}
end
# Catch cancel_ping failures with feedback
def handle_ui_event(
"cancel_ping",
_event,
%{assigns: %{main_character_id: nil}} = socket
) do
{:noreply, socket}
end
# Catch-all for cancel_ping to debug why it doesn't match
def handle_ui_event(
"cancel_ping",
event,
%{assigns: assigns} = socket
) do
{:noreply, socket}
end
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)

View File

@@ -11,6 +11,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
{:ok,
assign(socket,
is_topping_up?: false,
is_withdrawing?: false,
error: nil
)}
end
@@ -61,12 +62,102 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
{"ALL", nil}
]
)
|> assign(is_topping_up?: true)}
|> assign(is_topping_up?: true, is_withdrawing?: false)}
@impl true
def handle_event("hide_topup", _, socket),
do: {:noreply, socket |> assign(is_topping_up?: false)}
@impl true
def handle_event("show_withdraw", _, socket),
do:
{:noreply,
socket
|> assign(
:withdraw_amounts,
[
{"50M", 50_000_000},
{"100M", 100_000_000},
{"250M", 250_000_000},
{"500M", 500_000_000},
{"1B", 1_000_000_000},
{"2.5B", 2_500_000_000},
{"5B", 5_000_000_000},
{"10B", 10_000_000_000},
{"ALL", nil}
]
)
|> assign(is_withdrawing?: true, is_topping_up?: false)}
@impl true
def handle_event("hide_withdraw", _, socket),
do: {:noreply, socket |> assign(is_withdrawing?: false)}
@impl true
def handle_event(
"withdraw",
%{"amount" => amount} = _event,
%{assigns: %{current_user: current_user, map: map, map_id: map_id}} = socket
) do
user =
current_user.id
|> WandererApp.User.load()
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
amount =
if amount == "" do
map_balance
else
amount |> Decimal.new() |> Decimal.to_float()
end
case amount <= map_balance do
true ->
{:ok, _t} =
WandererApp.Api.MapTransaction.create(%{
map_id: map_id,
user_id: current_user.id,
amount: amount,
type: :out
})
{:ok, user_balance} =
user
|> WandererApp.User.get_balance()
{:ok, _user} =
user
|> WandererApp.Api.User.update_balance(%{
balance: (user_balance || 0.0) + amount
})
{:ok, user_balance} =
current_user.id
|> WandererApp.User.load()
|> WandererApp.User.get_balance()
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
{:noreply,
socket
|> assign(
is_withdrawing?: false,
map_balance: map_balance,
user_balance: user_balance
)}
_ ->
notify_to(
socket.assigns.notify_to,
socket.assigns.event_name,
{:flash, :error, "Not enough ISK in map balance!"}
)
{:noreply, socket}
end
end
@impl true
def handle_event(
"topup",
@@ -142,7 +233,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
<div class="stat">
<div class="stat-figure text-primary">
<.button
:if={not @is_topping_up?}
:if={not @is_topping_up? and not @is_withdrawing?}
class="mt-2"
type="button"
phx-click="show_topup"
@@ -150,6 +241,15 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
>
Top Up
</.button>
<.button
:if={not @is_topping_up? and not @is_withdrawing?}
class="mt-2"
type="button"
phx-click="show_withdraw"
phx-target={@myself}
>
Withdraw
</.button>
</div>
<div class="stat-title">Map balance</div>
<div class="stat-value text-white">
@@ -210,6 +310,32 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
</.button>
</div>
</.form>
<.form
:let={f}
:if={@is_withdrawing?}
for={@topup_form}
class="mt-2"
phx-submit="withdraw"
phx-target={@myself}
>
<.input
type="select"
field={f[:amount]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Withdraw amount"
placeholder="Select withdraw amount"
options={@withdraw_amounts}
/>
<div class="modal-action">
<.button class="mt-2" type="button" phx-click="hide_withdraw" phx-target={@myself}>
Cancel
</.button>
<.button class="mt-2" type="submit">
Withdraw
</.button>
</div>
</.form>
</div>
"""
end

View File

@@ -506,6 +506,7 @@ defmodule WandererAppWeb.Router do
live("/maps", AdminMapsLive, :index)
live("/maps/:id/edit", AdminMapsLive, :edit)
live("/maps/:id/acls", AdminMapsLive, :view_acls)
live("/characters", AdminCharactersLive, :index)
end
error_tracker_dashboard("/errors",

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.91.7"
@version "1.94.0"
def project do
[