Merge pull request #556 from guarzo/guarzo/ticker2andsse
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled

fix: sse enable checkbox, and kills ticker
This commit is contained in:
Dmitry Popov
2025-11-25 15:33:05 +04:00
committed by GitHub
11 changed files with 199 additions and 106 deletions
+29 -20
View File
@@ -8,6 +8,8 @@ defmodule WandererApp.Api.Map do
alias Ash.Resource.Change.Builtins
require Logger
postgres do
repo(WandererApp.Repo)
table("maps_v1")
@@ -55,6 +57,7 @@ defmodule WandererApp.Api.Map do
define(:mark_as_deleted, action: :mark_as_deleted)
define(:update_api_key, action: :update_api_key)
define(:toggle_webhooks, action: :toggle_webhooks)
define(:toggle_sse, action: :toggle_sse)
define(:by_id,
get_by: [:id],
@@ -103,7 +106,16 @@ defmodule WandererApp.Api.Map do
end
create :new do
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id, :sse_enabled]
accept [
:name,
:slug,
:description,
:scope,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
primary?(true)
argument :create_default_acl, :boolean, allow_nil?: true
argument :acls, {:array, :uuid}, allow_nil?: true
@@ -188,6 +200,14 @@ defmodule WandererApp.Api.Map do
require_atomic? false
end
update :toggle_sse do
require_atomic? false
accept [:sse_enabled]
# Validate subscription when enabling SSE
validate &validate_sse_subscription/2
end
create :duplicate do
accept [:name, :description, :scope, :only_tracked_characters]
argument :source_map_id, :uuid, allow_nil?: false
@@ -373,19 +393,13 @@ defmodule WandererApp.Api.Map do
end
end
# Private validation functions
@doc false
# Validates that SSE can be enabled based on subscription status.
# SSE Subscription Validation
#
# Validation rules:
# 1. Skip if SSE not being enabled (no validation needed)
# 2. Skip during map creation (map_id is nil, subscription doesn't exist yet)
# 3. Skip in Community Edition mode (subscriptions disabled globally)
# 4. Require active subscription in Enterprise mode
#
# This ensures users cannot enable SSE without a valid subscription in Enterprise mode,
# while allowing SSE in Community Edition and during map creation.
# This validation ensures that SSE can only be enabled when:
# 1. SSE is being disabled (always allowed)
# 2. Map is being created (skip validation, will be checked on first update)
# 3. Community Edition mode (always allowed)
# 4. Enterprise mode with active subscription
defp validate_sse_subscription(changeset, _context) do
sse_enabled = Ash.Changeset.get_attribute(changeset, :sse_enabled)
map_id = changeset.data.id
@@ -397,7 +411,6 @@ defmodule WandererApp.Api.Map do
:ok
# Map creation (no ID yet) - skip validation
# Subscription check will happen on first update if they try to enable SSE
is_nil(map_id) ->
:ok
@@ -411,7 +424,6 @@ defmodule WandererApp.Api.Map do
end
end
# Helper to check if map has an active subscription
defp validate_active_subscription(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} ->
@@ -421,11 +433,8 @@ defmodule WandererApp.Api.Map do
{:error, field: :sse_enabled, message: "Active subscription required to enable SSE"}
{:error, reason} ->
require Logger
Logger.warning("Failed to check subscription for map #{map_id}: #{inspect(reason)}")
# Fail open - allow the operation but log the error
# This prevents database errors from blocking legitimate operations
:ok
Logger.error("Error checking subscription status: #{inspect(reason)}")
{:error, field: :sse_enabled, message: "Unable to verify subscription status"}
end
end
end
@@ -58,6 +58,7 @@ defmodule WandererApp.Api.MapWebhookSubscription do
:consecutive_failures,
:secret
]
require_atomic? false
end
+4 -2
View File
@@ -463,7 +463,8 @@ defmodule WandererApp.Esi.ApiClient do
{:error, reason} ->
# Check if this is a Finch pool error
if is_exception(reason) and Exception.message(reason) =~ "unable to provide a connection" do
if is_exception(reason) and
Exception.message(reason) =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
@@ -677,7 +678,8 @@ defmodule WandererApp.Esi.ApiClient do
{:error, reason} ->
# Check if this is a Finch pool error
if is_exception(reason) and Exception.message(reason) =~ "unable to provide a connection" do
if is_exception(reason) and
Exception.message(reason) =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
+24 -14
View File
@@ -403,10 +403,24 @@ defmodule WandererApp.Kills.MessageHandler do
defp extract_field(_data, _field_names), do: nil
# Specific field extractors using the generic function
# Generic nested field extraction - tries flat keys first, then nested object
@spec extract_nested_field(map(), list(String.t()), String.t(), String.t()) :: String.t() | nil
defp extract_nested_field(data, flat_keys, nested_key, field) when is_map(data) do
case extract_field(data, flat_keys) do
nil ->
case data[nested_key] do
%{^field => value} when is_binary(value) and value != "" -> value
_ -> nil
end
value ->
value
end
end
# Specific field extractors using the generic functions
@spec get_character_name(map() | any()) :: String.t() | nil
defp get_character_name(data) when is_map(data) do
# Try multiple possible field names
field_names = ["attacker_name", "victim_name", "character_name", "name"]
extract_field(data, field_names) ||
@@ -419,30 +433,26 @@ defmodule WandererApp.Kills.MessageHandler do
defp get_character_name(_), do: nil
@spec get_corp_ticker(map() | any()) :: String.t() | nil
defp get_corp_ticker(data) when is_map(data) do
extract_field(data, ["corporation_ticker", "corp_ticker"])
end
defp get_corp_ticker(data) when is_map(data),
do: extract_nested_field(data, ["corporation_ticker", "corp_ticker"], "corporation", "ticker")
defp get_corp_ticker(_), do: nil
@spec get_corp_name(map() | any()) :: String.t() | nil
defp get_corp_name(data) when is_map(data) do
extract_field(data, ["corporation_name", "corp_name"])
end
defp get_corp_name(data) when is_map(data),
do: extract_nested_field(data, ["corporation_name", "corp_name"], "corporation", "name")
defp get_corp_name(_), do: nil
@spec get_alliance_ticker(map() | any()) :: String.t() | nil
defp get_alliance_ticker(data) when is_map(data) do
extract_field(data, ["alliance_ticker"])
end
defp get_alliance_ticker(data) when is_map(data),
do: extract_nested_field(data, ["alliance_ticker"], "alliance", "ticker")
defp get_alliance_ticker(_), do: nil
@spec get_alliance_name(map() | any()) :: String.t() | nil
defp get_alliance_name(data) when is_map(data) do
extract_field(data, ["alliance_name"])
end
defp get_alliance_name(data) when is_map(data),
do: extract_nested_field(data, ["alliance_name"], "alliance", "name")
defp get_alliance_name(_), do: nil
@@ -336,8 +336,8 @@
label="Valid"
options={Enum.map(@valid_types, fn valid_type -> {valid_type.label, valid_type.id} end)}
/>
<!-- Modal action buttons -->
<!-- Modal action buttons -->
<div class="modal-action">
<.button class="mt-2" type="submit" phx-disable-with="Saving...">
{(@live_action == :add_invite_link && "Add") || "Save"}
@@ -163,6 +163,7 @@ defmodule WandererAppWeb.MapsLive do
|> assign(:map_slug, map_slug)
|> assign(:map_id, map.id)
|> assign(:public_api_key, map.public_api_key)
|> assign(:sse_enabled, map.sse_enabled)
|> assign(:map, map)
|> assign(
export_settings: export_settings |> _get_export_map_data(),
@@ -232,6 +233,27 @@ defmodule WandererAppWeb.MapsLive do
{:noreply, assign(socket, public_api_key: new_api_key)}
end
def handle_event("toggle-sse", _params, socket) do
new_sse_enabled = not socket.assigns.sse_enabled
map = socket.assigns.map
case WandererApp.Api.Map.toggle_sse(map, %{sse_enabled: new_sse_enabled}) do
{:ok, updated_map} ->
{:noreply, assign(socket, sse_enabled: new_sse_enabled, map: updated_map)}
{:error, %Ash.Error.Invalid{errors: errors}} ->
error_message =
errors
|> Enum.map(fn error -> Map.get(error, :message, "Unknown error") end)
|> Enum.join(", ")
{:noreply, put_flash(socket, :error, error_message)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update SSE setting")}
end
end
@impl true
def handle_event(
"live_select_change",
@@ -540,6 +540,24 @@
</.button>
</div>
</div>
<div class="border-t border-stone-700 mt-4 pt-4">
<h3 class="text-md font-semibold mb-3">Server-Sent Events (SSE)</h3>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={@sse_enabled}
phx-click="toggle-sse"
/>
<span>Enable SSE for this map</span>
</label>
</div>
<p class="text-sm text-stone-400 mt-2">
When enabled, external clients can subscribe to real-time map events via SSE.
</p>
</div>
</div>
<.live_component
@@ -52,25 +52,24 @@ defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do
end
defp create_backup_table do
repo().query!("""
CREATE TABLE IF NOT EXISTS maps_v1_api_key_backup (
id UUID PRIMARY KEY,
map_id UUID NOT NULL,
old_public_api_key TEXT NOT NULL,
reason TEXT NOT NULL,
backed_up_at TIMESTAMP NOT NULL DEFAULT NOW()
repo().query!(
"""
CREATE TABLE IF NOT EXISTS maps_v1_api_key_backup (
id UUID PRIMARY KEY,
map_id UUID NOT NULL,
old_public_api_key TEXT NOT NULL,
reason TEXT NOT NULL,
backed_up_at TIMESTAMP NOT NULL DEFAULT NOW()
)
""",
[]
)
""", [])
IO.puts("Created backup table maps_v1_api_key_backup")
end
def down do
drop_if_exists(
index(:maps_v1, [:public_api_key],
name: :maps_v1_unique_public_api_key_index
)
)
drop_if_exists(index(:maps_v1, [:public_api_key], name: :maps_v1_unique_public_api_key_index))
IO.puts("Dropped unique index on maps_v1.public_api_key")
@@ -119,6 +118,7 @@ defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do
INSERT INTO maps_v1_api_key_backup (id, map_id, old_public_api_key, reason)
VALUES (gen_random_uuid(), $1::uuid, $2, 'duplicate_api_key_cleared_for_unique_index')
"""
repo().query!(backup_query, [id, api_key])
# Clear the duplicate
@@ -247,9 +247,10 @@ defmodule WandererAppWeb.Api.V1.MapSystemApiV1Test do
payload = %{
"data" => %{
"type" => "map_systems",
"attributes" => %{
# Missing solar_system_id - JSON:API returns 400 for schema validation
}
"attributes" =>
%{
# Missing solar_system_id - JSON:API returns 400 for schema validation
}
}
}
@@ -47,24 +47,26 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
user = create_user(%{name: "Test User", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
# Create test character with location tracking scopes
character = create_character(%{
eve_id: "#{@test_character_eve_id}",
name: "Test Character",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
character =
create_character(%{
eve_id: "#{@test_character_eve_id}",
name: "Test Character",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create test map
# Note: scope: :all is used because :none prevents system addition
# (is_connection_valid returns false for :none scope)
map = create_map(%{
name: "Test Char Track",
slug: "test-char-tracking-#{:rand.uniform(1_000_000)}",
owner_id: character.id,
scope: :all,
only_tracked_characters: false
})
map =
create_map(%{
name: "Test Char Track",
slug: "test-char-tracking-#{:rand.uniform(1_000_000)}",
owner_id: character.id,
scope: :all,
only_tracked_characters: false
})
on_exit(fn ->
cleanup_test_data(map.id)
@@ -150,6 +152,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Setup: Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -157,6 +160,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
@@ -167,8 +171,11 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
CharactersImpl.update_characters(map.id)
# Verify: Both systems should be on map after character moves
assert wait_for_system_on_map(map.id, @system_jita), "Jita should be added after character moves"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should be added as the new location"
assert wait_for_system_on_map(map.id, @system_jita),
"Jita should be added after character moves"
assert wait_for_system_on_map(map.id, @system_amarr),
"Amarr should be added as the new location"
end
end
@@ -185,6 +192,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -192,6 +200,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
@@ -202,7 +211,9 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
CharactersImpl.update_characters(map.id)
# Verify both Jita and Amarr are now on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should be on map after movement"
assert wait_for_system_on_map(map.id, @system_jita),
"Jita (start) should be on map after movement"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should be on map"
# Rapid jump to Dodixie before next update cycle
@@ -213,7 +224,10 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify: All three systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should still be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr (intermediate) should still be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_amarr),
"Amarr (intermediate) should still be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_dodixie), "Dodixie (end) should be on map"
end
@@ -230,6 +244,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Start at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -284,9 +299,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify start_solar_system_id still exists after first update
{:ok, start_system} =
WandererApp.Cache.lookup(
"map:#{map.id}:character:#{character.id}:start_solar_system_id"
)
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:start_solar_system_id")
assert start_system == @system_jita,
"start_solar_system_id should persist after first update (not be taken/removed)"
@@ -369,6 +382,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set character at Jita and set start location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -401,6 +415,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set up character location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -424,19 +439,22 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Create a second character
user2 = create_user(%{name: "Test User 2", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
character2 = create_character(%{
eve_id: "#{@test_character_eve_id + 1}",
name: "Test Character 2",
user_id: user2.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
character2 =
create_character(%{
eve_id: "#{@test_character_eve_id + 1}",
name: "Test Character 2",
user_id: user2.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Add both characters to map presence
add_character_to_map_presence(map.id, character2.id)
# Set locations for both characters
set_character_location(character2.id, @system_amarr)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character2.id}:start_solar_system_id",
@system_amarr
@@ -464,6 +482,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set up character with location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -491,32 +510,38 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
ensure_map_started(map.id)
# Create multiple characters for concurrent processing
characters = for i <- 1..5 do
user = create_user(%{
name: "Test User #{i}",
hash: "test_hash_#{:rand.uniform(1_000_000)}"
})
characters =
for i <- 1..5 do
user =
create_user(%{
name: "Test User #{i}",
hash: "test_hash_#{:rand.uniform(1_000_000)}"
})
character = create_character(%{
eve_id: "#{@test_character_eve_id + i}",
name: "Test Character #{i}",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
character =
create_character(%{
eve_id: "#{@test_character_eve_id + i}",
name: "Test Character #{i}",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Add character to presence and set location
add_character_to_map_presence(map.id, character.id)
# Add character to presence and set location
add_character_to_map_presence(map.id, character.id)
solar_system_id = Enum.at([@system_jita, @system_amarr, @system_dodixie, @system_rens], rem(i, 4))
set_character_location(character.id, solar_system_id)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
solar_system_id
)
solar_system_id =
Enum.at([@system_jita, @system_amarr, @system_dodixie, @system_rens], rem(i, 4))
character
end
set_character_location(character.id, solar_system_id)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
solar_system_id
)
character
end
# Run update_characters - should handle all characters concurrently
result = CharactersImpl.update_characters(map.id)
@@ -563,7 +588,8 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
CharactersImpl.update_characters(map.id)
# Should receive start and complete events (or error event if something failed)
assert_receive {:telemetry_event, [:wanderer_app, :map, :update_characters, :start], _, _}, 1000
assert_receive {:telemetry_event, [:wanderer_app, :map, :update_characters, :start], _, _},
1000
# Should receive either complete or error event
receive do
@@ -593,6 +619,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set location in character cache
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
@@ -613,10 +640,12 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify both caches updated
{:ok, character_data} = Cachex.get(:character_cache, character.id)
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
assert character_data.solar_system_id == @system_amarr
assert map_cached_location == @system_amarr,
"Both caches should be consistent after update"
end
+2 -1
View File
@@ -356,7 +356,8 @@ defmodule WandererApp.MapTestHelpers do
def set_character_location(character_id, solar_system_id, opts \\ []) do
structure_id = opts[:structure_id]
station_id = opts[:station_id]
ship = opts[:ship] || 670 # Capsule
# Capsule
ship = opts[:ship] || 670
# First get the existing character from cache or database to maintain all fields
{:ok, existing_character} = WandererApp.Character.get_character(character_id)