mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-30 13:03:53 +00:00
Compare commits
181 Commits
fix-error-
...
v1.88.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc47dc5f81 | ||
|
|
dc81cffeea | ||
|
|
5766fcf4d8 | ||
|
|
c57a3b2cea | ||
|
|
0c1fa8e79b | ||
|
|
36cc91915c | ||
|
|
bb644fde31 | ||
|
|
269b54d382 | ||
|
|
a9115cc653 | ||
|
|
eeea7aee8b | ||
|
|
700089e381 | ||
|
|
932935557c | ||
|
|
2890a76cf2 | ||
|
|
4ac9b2e2b7 | ||
|
|
f92436f3f0 | ||
|
|
22d97cc99d | ||
|
|
305838573c | ||
|
|
cc7ad81d2f | ||
|
|
a694e57512 | ||
|
|
20be7fc67d | ||
|
|
54bfee414b | ||
|
|
bcfa47bd94 | ||
|
|
b784f68818 | ||
|
|
344ee54018 | ||
|
|
42e0f8f660 | ||
|
|
99b081887c | ||
|
|
dee8d0dae8 | ||
|
|
147dd5880e | ||
|
|
69991fff72 | ||
|
|
b881c84a52 | ||
|
|
de4e1f859f | ||
|
|
8e2a19540c | ||
|
|
855c596672 | ||
|
|
36d3c0937b | ||
|
|
d8fb1f78cf | ||
|
|
98fa7e0235 | ||
|
|
e4396fe2f9 | ||
|
|
1c117903f6 | ||
|
|
9e9dc39200 | ||
|
|
abd7e4e15c | ||
|
|
88ed9cd39e | ||
|
|
9666a8e78a | ||
|
|
271a3d90f8 | ||
|
|
01e291daf4 | ||
|
|
b7c0b45c15 | ||
|
|
0874e3c51c | ||
|
|
d39fa0363a | ||
|
|
369b08a9ae | ||
|
|
a872561b18 | ||
|
|
857608f8ef | ||
|
|
7a74ae566b | ||
|
|
f2c8724763 | ||
|
|
9a8dc4dbe5 | ||
|
|
01192dc637 | ||
|
|
957cbcc561 | ||
|
|
7eb6d093cf | ||
|
|
a23e544a9f | ||
|
|
845ea7a576 | ||
|
|
ae8fbf30e4 | ||
|
|
083e300ff5 | ||
|
|
ae4ebc0e36 | ||
|
|
3de385c902 | ||
|
|
5f3d4dba37 | ||
|
|
8acc7ddc25 | ||
|
|
c175f19142 | ||
|
|
ed6d25f3ea | ||
|
|
ab07d1321d | ||
|
|
a81e61bd70 | ||
|
|
d2d33619c2 | ||
|
|
fa464110c6 | ||
|
|
a5fa60e699 | ||
|
|
6db994852f | ||
|
|
0a68676957 | ||
|
|
9b82dd8f43 | ||
|
|
aac2c33fd2 | ||
|
|
0ebc703774 | ||
|
|
4615e20838 | ||
|
|
f4d28f282a | ||
|
|
1fe8ef17bd | ||
|
|
1665b65619 | ||
|
|
e1a946bb1d | ||
|
|
543ec7f071 | ||
|
|
bf40d2cb8d | ||
|
|
48ac40ea55 | ||
|
|
5a3f3c40fe | ||
|
|
d5bac311ff | ||
|
|
34a7c854ed | ||
|
|
6088afb38c | ||
|
|
5764c41d23 | ||
|
|
09444596ff | ||
|
|
ee15d90f9c | ||
|
|
f5b014dae9 | ||
|
|
ebb6090be9 | ||
|
|
7a4d31db60 | ||
|
|
2acf9ed5dc | ||
|
|
46df025200 | ||
|
|
43a363b5ab | ||
|
|
03688387d8 | ||
|
|
5060852918 | ||
|
|
57381b9782 | ||
|
|
6014c60e13 | ||
|
|
1b711d7b4b | ||
|
|
f761ba9746 | ||
|
|
20a795c5b5 | ||
|
|
0c80894c65 | ||
|
|
21844f0550 | ||
|
|
f7716ca45a | ||
|
|
de74714c77 | ||
|
|
4dfa83bd30 | ||
|
|
cb4dba8dc2 | ||
|
|
1d75b8f063 | ||
|
|
2a42c4e6df | ||
|
|
0ee6160bcd | ||
|
|
5826d2492b | ||
|
|
a643e20247 | ||
|
|
66dc680281 | ||
|
|
5e0965ead4 | ||
|
|
46f46c745e | ||
|
|
00bf620e35 | ||
|
|
46eef60d86 | ||
|
|
712379f4bb | ||
|
|
4c39c6fb39 | ||
|
|
fe836442ab | ||
|
|
9514806dbb | ||
|
|
4e6423ebc8 | ||
|
|
a97e598299 | ||
|
|
9c26b50aac | ||
|
|
3f2ddf5cc4 | ||
|
|
233b2bd7a4 | ||
|
|
0d35268efc | ||
|
|
d169220eb2 | ||
|
|
182d5ec9fb | ||
|
|
32958253b7 | ||
|
|
c011d56ce7 | ||
|
|
73d1921d42 | ||
|
|
7bb810e1e6 | ||
|
|
c90ac7b1e3 | ||
|
|
005e0c2bc6 | ||
|
|
808acb540e | ||
|
|
06626f910b | ||
|
|
a14e829f09 | ||
|
|
4002285882 | ||
|
|
d732d15ef6 | ||
|
|
812582d955 | ||
|
|
f3077c0bf1 | ||
|
|
32c70cbbad | ||
|
|
8934935e10 | ||
|
|
20c8a53712 | ||
|
|
b22970fef3 | ||
|
|
cf72394ef9 | ||
|
|
e6dbba7283 | ||
|
|
843b3b86b2 | ||
|
|
bd865b9f64 | ||
|
|
ae91cd2f92 | ||
|
|
0be7a5f9d0 | ||
|
|
e15bfa426a | ||
|
|
4198e4b07a | ||
|
|
03ee08ff67 | ||
|
|
ac4dd4c28b | ||
|
|
308e81a464 | ||
|
|
6f4240d931 | ||
|
|
847b45a431 | ||
|
|
7613ca78da | ||
|
|
5ec97d74ca | ||
|
|
74359a5542 | ||
|
|
0020f46dd8 | ||
|
|
c8631708b9 | ||
|
|
a6751b45c6 | ||
|
|
f48aeb5cec | ||
|
|
a5f25646c9 | ||
|
|
23cf1fd96f | ||
|
|
6f15521069 | ||
|
|
9d41e57c06 | ||
|
|
ea9a22df09 | ||
|
|
0d4fd6f214 | ||
|
|
87a6c20545 | ||
|
|
c375f4e4ce | ||
|
|
843a6d7320 | ||
|
|
63ca473113 | ||
|
|
7df8284124 | ||
|
|
21ca630abd |
@@ -1,9 +1,9 @@
|
||||
name: Build Docker Image
|
||||
name: Build Develop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '**'
|
||||
branches:
|
||||
- develop
|
||||
|
||||
env:
|
||||
MIX_ENV: prod
|
||||
@@ -18,12 +18,85 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 🛠 Build
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.ref == 'refs/heads/develop' && github.event_name == 'push' }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
repository-projects: write
|
||||
strategy:
|
||||
matrix:
|
||||
otp: ["27"]
|
||||
elixir: ["1.17"]
|
||||
node-version: ["18.x"]
|
||||
outputs:
|
||||
commit_hash: ${{ steps.set-commit-develop.outputs.commit_hash }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup Elixir
|
||||
uses: erlef/setup-beam@v1
|
||||
with:
|
||||
otp-version: ${{matrix.otp}}
|
||||
elixir-version: ${{matrix.elixir}}
|
||||
# nix build would also work here because `todos` is the default package
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ssh-key: "${{ secrets.COMMIT_KEY }}"
|
||||
fetch-depth: 0
|
||||
- name: 😅 Cache deps
|
||||
id: cache-deps
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-elixir-deps
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-
|
||||
- name: 😅 Cache compiled build
|
||||
id: cache-build
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-compiled-build
|
||||
with:
|
||||
path: |
|
||||
_build
|
||||
key: ${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles( '**/lib/**/*.{ex,eex}', '**/config/*.exs', '**/mix.exs' ) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-
|
||||
${{ runner.os }}-build-
|
||||
# Step: Download project dependencies. If unchanged, uses
|
||||
# the cached version.
|
||||
- name: 🌐 Install dependencies
|
||||
run: mix deps.get --only "prod"
|
||||
|
||||
# Step: Compile the project treating any warnings as errors.
|
||||
# Customize this step if a different behavior is desired.
|
||||
- name: 🛠 Compiles without warnings
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
run: mix compile
|
||||
|
||||
- name: Set commit hash for develop
|
||||
id: set-commit-develop
|
||||
run: |
|
||||
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
docker:
|
||||
name: 🛠 Build Docker Images
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
release-notes: ${{ steps.get-content.outputs.string }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
@@ -37,6 +110,7 @@ jobs:
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
@@ -46,25 +120,9 @@ jobs:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ needs.build.outputs.commit_hash }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Release Tag
|
||||
id: get-latest-tag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: 1.0.0
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare Changelog
|
||||
run: |
|
||||
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
|
||||
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -113,24 +171,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- uses: markpatterson27/markdown-to-output@v1
|
||||
id: extract-changelog
|
||||
with:
|
||||
filepath: CHANGELOG.md
|
||||
|
||||
- name: Get content
|
||||
uses: 2428392/gh-truncate-string-action@v1.3.0
|
||||
id: get-content
|
||||
with:
|
||||
stringToTruncate: |
|
||||
📣 Wanderer new release available 🎉
|
||||
|
||||
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
|
||||
${{ steps.extract-changelog.outputs.body }}
|
||||
maxLength: 500
|
||||
truncationSymbol: "…"
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -161,9 +201,8 @@ jobs:
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
|
||||
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
|
||||
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
@@ -176,12 +215,20 @@ jobs:
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
notify:
|
||||
name: 🏷 Notify about release
|
||||
name: 🏷 Notify about develop release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [docker, merge]
|
||||
steps:
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@v5.3.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
content: ${{ needs.docker.outputs.release-notes }}
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL_DEV }}
|
||||
content: |
|
||||
📣 New develop release available 🚀
|
||||
|
||||
**Commit**: `${{ github.sha }}`
|
||||
**Status**: Development/Testing Release
|
||||
|
||||
Docker image: `wandererltd/community-edition:develop`
|
||||
|
||||
⚠️ This is an unstable development release for testing purposes.
|
||||
56
.github/workflows/build.yml
vendored
56
.github/workflows/build.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
env:
|
||||
MIX_ENV: prod
|
||||
@@ -22,7 +21,7 @@ jobs:
|
||||
build:
|
||||
name: 🛠 Build
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push' }}
|
||||
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
@@ -37,7 +36,7 @@ jobs:
|
||||
elixir: ["1.17"]
|
||||
node-version: ["18.x"]
|
||||
outputs:
|
||||
commit_hash: ${{ steps.generate-changelog.outputs.commit_hash || steps.set-commit-develop.outputs.commit_hash }}
|
||||
commit_hash: ${{ steps.generate-changelog.outputs.commit_hash }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
@@ -91,7 +90,6 @@ jobs:
|
||||
|
||||
- name: Generate Changelog & Update Tag Version
|
||||
id: generate-changelog
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config --global user.name 'CI'
|
||||
git config --global user.email 'ci@users.noreply.github.com'
|
||||
@@ -102,15 +100,16 @@ jobs:
|
||||
|
||||
- name: Set commit hash for develop
|
||||
id: set-commit-develop
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
run: |
|
||||
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
docker:
|
||||
name: 🛠 Build Docker Images
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
release-notes: ${{ steps.get-content.outputs.string }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
@@ -137,6 +136,17 @@ jobs:
|
||||
ref: ${{ needs.build.outputs.commit_hash }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Release Tag
|
||||
id: get-latest-tag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: 1.0.0
|
||||
|
||||
- name: Prepare Changelog
|
||||
run: |
|
||||
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
|
||||
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -185,6 +195,24 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- uses: markpatterson27/markdown-to-output@v1
|
||||
id: extract-changelog
|
||||
with:
|
||||
filepath: CHANGELOG.md
|
||||
|
||||
- name: Get content
|
||||
uses: 2428392/gh-truncate-string-action@v1.3.0
|
||||
id: get-content
|
||||
with:
|
||||
stringToTruncate: |
|
||||
📣 Wanderer new release available 🎉
|
||||
|
||||
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
|
||||
${{ steps.extract-changelog.outputs.body }}
|
||||
maxLength: 500
|
||||
truncationSymbol: "…"
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -215,8 +243,9 @@ jobs:
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
|
||||
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
@@ -259,3 +288,14 @@ jobs:
|
||||
## How to Promote?
|
||||
In order to promote this to prod, edit the draft and press **"Publish release"**.
|
||||
draft: true
|
||||
|
||||
notify:
|
||||
name: 🏷 Notify about release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [docker, merge]
|
||||
steps:
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@v5.3.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
content: ${{ needs.docker.outputs.release-notes }}
|
||||
|
||||
187
.github/workflows/docker-arm.yml
vendored
187
.github/workflows/docker-arm.yml
vendored
@@ -1,187 +0,0 @@
|
||||
name: Build Docker ARM Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
MIX_ENV: prod
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REGISTRY_IMAGE: wandererltd/community-edition-arm
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: 🛠 Build Docker Images
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
release-notes: ${{ steps.get-content.outputs.string }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
repository-projects: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Release Tag
|
||||
id: get-latest-tag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: 1.0.0
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare Changelog
|
||||
run: |
|
||||
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
|
||||
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.WANDERER_DOCKER_USER }}
|
||||
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
MIX_ENV=prod
|
||||
BUILD_METADATA=${{ steps.meta.outputs.json }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- uses: markpatterson27/markdown-to-output@v1
|
||||
id: extract-changelog
|
||||
with:
|
||||
filepath: CHANGELOG.md
|
||||
|
||||
- name: Get content
|
||||
uses: 2428392/gh-truncate-string-action@v1.3.0
|
||||
id: get-content
|
||||
with:
|
||||
stringToTruncate: |
|
||||
📣 Wanderer **ARM** release available 🎉
|
||||
|
||||
**Version**: :${{ steps.get-latest-tag.outputs.tag }}
|
||||
|
||||
${{ steps.extract-changelog.outputs.body }}
|
||||
maxLength: 500
|
||||
truncationSymbol: "…"
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- docker
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.WANDERER_DOCKER_USER }}
|
||||
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
notify:
|
||||
name: 🏷 Notify about release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [docker, merge]
|
||||
steps:
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@v5.3.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
content: ${{ needs.docker.outputs.release-notes }}
|
||||
361
CHANGELOG.md
361
CHANGELOG.md
@@ -2,6 +2,367 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.88.6](https://github.com/wanderer-industries/wanderer/compare/v1.88.5...v1.88.6) (2025-11-28)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed tracking issues
|
||||
|
||||
## [v1.88.5](https://github.com/wanderer-industries/wanderer/compare/v1.88.4...v1.88.5) (2025-11-28)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed env errors
|
||||
|
||||
## [v1.88.4](https://github.com/wanderer-industries/wanderer/compare/v1.88.3...v1.88.4) (2025-11-27)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* defensive check for undefined excluded systems
|
||||
|
||||
## [v1.88.3](https://github.com/wanderer-industries/wanderer/compare/v1.88.2...v1.88.3) (2025-11-26)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed env issues
|
||||
|
||||
## [v1.88.1](https://github.com/wanderer-industries/wanderer/compare/v1.88.0...v1.88.1) (2025-11-26)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* sse enable checkbox, and kills ticker
|
||||
|
||||
* apiv1 token auth and structure fixes
|
||||
|
||||
* removed ipv6 distribution env settings
|
||||
|
||||
* tests: updated tests
|
||||
|
||||
* tests: updated tests
|
||||
|
||||
* clean up id generation
|
||||
|
||||
* resolve issue with async event processing
|
||||
|
||||
## [v1.88.0](https://github.com/wanderer-industries/wanderer/compare/v1.87.0...v1.88.0) (2025-11-25)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Add zkb and eve who links for characters where it possibly was add
|
||||
|
||||
## [v1.87.0](https://github.com/wanderer-industries/wanderer/compare/v1.86.1...v1.87.0) (2025-11-25)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Add support markdown for system description
|
||||
|
||||
## [v1.86.1](https://github.com/wanderer-industries/wanderer/compare/v1.86.0...v1.86.1) (2025-11-25)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Add ability to see character passage direction in list of passages
|
||||
|
||||
## [v1.86.0](https://github.com/wanderer-industries/wanderer/compare/v1.85.5...v1.86.0) (2025-11-25)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* add date filter for character activity
|
||||
|
||||
## [v1.85.5](https://github.com/wanderer-industries/wanderer/compare/v1.85.4...v1.85.5) (2025-11-24)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed connections cleanup and rally points delete issues
|
||||
|
||||
## [v1.85.4](https://github.com/wanderer-industries/wanderer/compare/v1.85.3...v1.85.4) (2025-11-22)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: invalidate map characters every 1 hour for any missing/revoked permissions
|
||||
|
||||
## [v1.85.3](https://github.com/wanderer-industries/wanderer/compare/v1.85.2...v1.85.3) (2025-11-22)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed connection time status issues. fixed character alliance update issues
|
||||
|
||||
## [v1.85.2](https://github.com/wanderer-industries/wanderer/compare/v1.85.1...v1.85.2) (2025-11-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: increased API pool limits
|
||||
|
||||
## [v1.85.1](https://github.com/wanderer-industries/wanderer/compare/v1.85.0...v1.85.1) (2025-11-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: increased API pool limits
|
||||
|
||||
## [v1.85.0](https://github.com/wanderer-industries/wanderer/compare/v1.84.37...v1.85.0) (2025-11-19)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* core: added support for new ship types
|
||||
|
||||
## [v1.84.37](https://github.com/wanderer-industries/wanderer/compare/v1.84.36...v1.84.37) (2025-11-19)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* auth: fixed character auth issues
|
||||
|
||||
## [v1.84.36](https://github.com/wanderer-industries/wanderer/compare/v1.84.35...v1.84.36) (2025-11-19)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* fixed duplicated map slugs
|
||||
|
||||
## [v1.84.35](https://github.com/wanderer-industries/wanderer/compare/v1.84.34...v1.84.35) (2025-11-19)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* structure search / paste issues
|
||||
|
||||
## [v1.84.34](https://github.com/wanderer-industries/wanderer/compare/v1.84.33...v1.84.34) (2025-11-18)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed character tracking issues
|
||||
|
||||
## [v1.84.33](https://github.com/wanderer-industries/wanderer/compare/v1.84.32...v1.84.33) (2025-11-18)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed character tracking issues
|
||||
|
||||
## [v1.84.32](https://github.com/wanderer-industries/wanderer/compare/v1.84.31...v1.84.32) (2025-11-18)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed character tracking issues
|
||||
|
||||
## [v1.84.31](https://github.com/wanderer-industries/wanderer/compare/v1.84.30...v1.84.31) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed connactions validation logic
|
||||
|
||||
## [v1.84.30](https://github.com/wanderer-industries/wanderer/compare/v1.84.29...v1.84.30) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.84.29](https://github.com/wanderer-industries/wanderer/compare/v1.84.28...v1.84.29) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.84.28](https://github.com/wanderer-industries/wanderer/compare/v1.84.27...v1.84.28) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed ACL updates
|
||||
|
||||
## [v1.84.27](https://github.com/wanderer-industries/wanderer/compare/v1.84.26...v1.84.27) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: supported characters_updates for external events
|
||||
|
||||
* core: improved character tracking
|
||||
|
||||
* core: improved character tracking
|
||||
|
||||
* core: improved character location tracking
|
||||
|
||||
## [v1.84.26](https://github.com/wanderer-industries/wanderer/compare/v1.84.25...v1.84.26) (2025-11-16)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: disable character tracker pausing
|
||||
|
||||
## [v1.84.25](https://github.com/wanderer-industries/wanderer/compare/v1.84.24...v1.84.25) (2025-11-16)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: used upsert for adding map systems
|
||||
|
||||
## [v1.84.24](https://github.com/wanderer-industries/wanderer/compare/v1.84.23...v1.84.24) (2025-11-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fixed problem related with error if settings was removed and mapper crashed. Fixed settings reset.
|
||||
|
||||
## [v1.84.23](https://github.com/wanderer-industries/wanderer/compare/v1.84.22...v1.84.23) (2025-11-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map pings cancel errors
|
||||
|
||||
## [v1.84.22](https://github.com/wanderer-industries/wanderer/compare/v1.84.21...v1.84.22) (2025-11-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map initialization
|
||||
|
||||
## [v1.84.21](https://github.com/wanderer-industries/wanderer/compare/v1.84.20...v1.84.21) (2025-11-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map characters adding
|
||||
|
||||
## [v1.84.20](https://github.com/wanderer-industries/wanderer/compare/v1.84.19...v1.84.20) (2025-11-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map start issues
|
||||
|
||||
## [v1.84.19](https://github.com/wanderer-industries/wanderer/compare/v1.84.18...v1.84.19) (2025-11-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map start issues
|
||||
|
||||
## [v1.84.18](https://github.com/wanderer-industries/wanderer/compare/v1.84.17...v1.84.18) (2025-11-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: added gracefull map poll recovery from saved state. added map slug unique checks
|
||||
|
||||
## [v1.84.17](https://github.com/wanderer-industries/wanderer/compare/v1.84.16...v1.84.17) (2025-11-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed activity tracking issues
|
||||
|
||||
## [v1.84.16](https://github.com/wanderer-industries/wanderer/compare/v1.84.15...v1.84.16) (2025-11-13)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: removed maps auto-start logic
|
||||
|
||||
## [v1.84.15](https://github.com/wanderer-industries/wanderer/compare/v1.84.14...v1.84.15) (2025-11-13)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed maps start/stop logic, added server downtime period support
|
||||
|
||||
## [v1.84.14](https://github.com/wanderer-industries/wanderer/compare/v1.84.13...v1.84.14) (2025-11-13)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fixed problem related with error if settings was removed and mapper crashed. Fixed settings reset.
|
||||
|
||||
## [v1.84.13](https://github.com/wanderer-industries/wanderer/compare/v1.84.12...v1.84.13) (2025-11-13)
|
||||
|
||||
|
||||
|
||||
5
Makefile
5
Makefile
@@ -30,10 +30,10 @@ format f:
|
||||
mix format
|
||||
|
||||
test t:
|
||||
mix test
|
||||
MIX_ENV=test mix test
|
||||
|
||||
coverage cover co:
|
||||
mix test --cover
|
||||
MIX_ENV=test mix test --cover
|
||||
|
||||
unit-tests ut:
|
||||
@echo "Running unit tests..."
|
||||
@@ -45,4 +45,3 @@ versions v:
|
||||
@cat .tool-versions
|
||||
@cat Aptfile
|
||||
@echo
|
||||
|
||||
|
||||
@@ -73,7 +73,9 @@ body > div:first-of-type {
|
||||
}
|
||||
|
||||
.maps_bg {
|
||||
background-image: url('../images/maps_bg.webp');
|
||||
/* OLD image */
|
||||
/* background-image: url('../images/maps_bg.webp'); */
|
||||
background-image: url('https://wanderer-industries.github.io/wanderer-assets/images/eve-screen-catalyst-expansion-bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
width: 100%;
|
||||
|
||||
@@ -51,20 +51,8 @@ export const Characters = ({ data }: CharactersProps) => {
|
||||
['border-lime-600/70']: character.online,
|
||||
},
|
||||
)}
|
||||
title={character.tracking_paused ? `${character.name} - Tracking Paused (click to resume)` : character.name}
|
||||
title={character.name}
|
||||
>
|
||||
{character.tracking_paused && (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute flex flex-col p-[2px] top-[0px] left-[0px] w-[35px] h-[35px]',
|
||||
'text-yellow-500 text-[9px] z-10 bg-gray-800/40',
|
||||
'pi',
|
||||
PrimeIcons.PAUSE,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{mainCharacterEveId === character.eve_id && (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use "sass:color";
|
||||
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
|
||||
@import '@/hooks/Mapper/components/map/styles/solar-system-node';
|
||||
@use '@/hooks/Mapper/components/map/styles/solar-system-node' as v;
|
||||
|
||||
@keyframes move-stripes {
|
||||
from {
|
||||
@@ -26,8 +26,8 @@
|
||||
background-color: var(--rf-node-bg-color, #202020) !important;
|
||||
color: var(--rf-text-color, #ffffff);
|
||||
|
||||
box-shadow: 0 0 5px rgba($dark-bg, 0.5);
|
||||
border: 1px solid color.adjust($pastel-blue, $lightness: -10%);
|
||||
box-shadow: 0 0 5px rgba(v.$dark-bg, 0.5);
|
||||
border: 1px solid color.adjust(v.$pastel-blue, $lightness: -10%);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -99,7 +99,7 @@
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
border-color: v.$pastel-pink;
|
||||
box-shadow: 0 0 10px #9a1af1c2;
|
||||
}
|
||||
|
||||
@@ -113,11 +113,11 @@
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
|
||||
border-color: $neon-color-1;
|
||||
border-color: v.$neon-color-1;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
$neon-color-3 0px,
|
||||
$neon-color-3 8px,
|
||||
v.$neon-color-3 0px,
|
||||
v.$neon-color-3 8px,
|
||||
transparent 8px,
|
||||
transparent 21px
|
||||
);
|
||||
@@ -146,7 +146,7 @@
|
||||
border: 1px solid var(--eve-solar-system-status-color-lookingFor-dark15);
|
||||
background-image: linear-gradient(275deg, #45ff8f2f, #457fff2f);
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
border-color: v.$pastel-pink;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,13 +347,13 @@
|
||||
.Handle {
|
||||
min-width: initial;
|
||||
min-height: initial;
|
||||
border: 1px solid $pastel-blue;
|
||||
border: 1px solid v.$pastel-blue;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
pointer-events: auto;
|
||||
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
border-color: v.$pastel-pink;
|
||||
}
|
||||
|
||||
&.HandleTop {
|
||||
|
||||
@@ -14,8 +14,27 @@ export const useCommandsCharacters = () => {
|
||||
const ref = useRef({ update });
|
||||
ref.current = { update };
|
||||
|
||||
const charactersUpdated = useCallback((characters: CommandCharactersUpdated) => {
|
||||
ref.current.update(() => ({ characters: characters.slice() }));
|
||||
const charactersUpdated = useCallback((updatedCharacters: CommandCharactersUpdated) => {
|
||||
ref.current.update(state => {
|
||||
const existing = state.characters ?? [];
|
||||
// Put updatedCharacters into a map keyed by ID
|
||||
const updatedMap = new Map(updatedCharacters.map(c => [c.eve_id, c]));
|
||||
|
||||
// 1. Update existing characters when possible
|
||||
const merged = existing.map(character => {
|
||||
const updated = updatedMap.get(character.eve_id);
|
||||
if (updated) {
|
||||
updatedMap.delete(character.eve_id); // Mark as processed
|
||||
return { ...character, ...updated };
|
||||
}
|
||||
return character;
|
||||
});
|
||||
|
||||
// 2. Any remaining items in updatedMap are NEW characters → add them
|
||||
const newCharacters = Array.from(updatedMap.values());
|
||||
|
||||
return { characters: [...merged, ...newCharacters] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const characterAdded = useCallback((value: CommandCharacterAdded) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MarkdownComment } from '@/hooks/Mapper/components/mapInterface/components/Comments/components';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CommentType } from '@/hooks/Mapper/types';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { CommentType } from '@/hooks/Mapper/types';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface CommentsProps {}
|
||||
|
||||
@@ -14,7 +14,9 @@ export const Comments = ({}: CommentsProps) => {
|
||||
comments: { loadComments, comments, lastUpdateKey },
|
||||
} = useMapRootState();
|
||||
|
||||
const [systemId] = selectedSystems;
|
||||
const systemId = useMemo(() => {
|
||||
return +selectedSystems[0];
|
||||
}, [selectedSystems]);
|
||||
|
||||
const ref = useRef({ loadComments, systemId });
|
||||
ref.current = { loadComments, systemId };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import classes from './MarkdownComment.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
InfoDrawer,
|
||||
@@ -49,7 +48,11 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm
|
||||
<>
|
||||
<InfoDrawer
|
||||
labelClassName="mb-[3px]"
|
||||
className={clsx(classes.MarkdownCommentRoot, 'p-1 bg-stone-700/20 ')}
|
||||
className={clsx(
|
||||
'p-1 bg-stone-700/20',
|
||||
'text-[12px] leading-[1.2] text-stone-300 break-words',
|
||||
'bg-gradient-to-r from-stone-600/40 via-stone-600/10 to-stone-600/0',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.CERoot {
|
||||
@apply border border-stone-400/30 rounded-[2px];
|
||||
|
||||
:global {
|
||||
.cm-content {
|
||||
@apply bg-stone-600/40;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import clsx from 'clsx';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { MarkdownEditor } from '@/hooks/Mapper/components/mapInterface/components/MarkdownEditor';
|
||||
import { useHotkey } from '@/hooks/Mapper/hooks';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { OutCommand } from '@/hooks/Mapper/types';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import classes from './CommentsEditor.module.scss';
|
||||
|
||||
export interface CommentsEditorProps {}
|
||||
|
||||
@@ -18,7 +19,9 @@ export const CommentsEditor = ({}: CommentsEditorProps) => {
|
||||
outCommand,
|
||||
} = useMapRootState();
|
||||
|
||||
const [systemId] = selectedSystems;
|
||||
const systemId = useMemo(() => {
|
||||
return +selectedSystems[0];
|
||||
}, [selectedSystems]);
|
||||
|
||||
const ref = useRef({ outCommand, systemId, textVal });
|
||||
ref.current = { outCommand, systemId, textVal };
|
||||
@@ -48,6 +51,7 @@ export const CommentsEditor = ({}: CommentsEditorProps) => {
|
||||
|
||||
return (
|
||||
<MarkdownEditor
|
||||
className={classes.CERoot}
|
||||
value={textVal}
|
||||
onChange={setTextVal}
|
||||
overlayContent={
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.CERoot {
|
||||
@apply border border-stone-400/30 rounded-[2px];
|
||||
@apply border border-stone-500/30 rounded-[2px];
|
||||
|
||||
:global {
|
||||
.cm-content {
|
||||
@apply bg-stone-600/40;
|
||||
@apply bg-stone-950/70;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
|
||||
@@ -44,9 +44,17 @@ export interface MarkdownEditorProps {
|
||||
overlayContent?: ReactNode;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
height?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownEditor = ({ value, onChange, overlayContent }: MarkdownEditorProps) => {
|
||||
export const MarkdownEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
overlayContent,
|
||||
height = '70px',
|
||||
className,
|
||||
}: MarkdownEditorProps) => {
|
||||
const [hasShift, setHasShift] = useState(false);
|
||||
|
||||
const refData = useRef({ onChange });
|
||||
@@ -66,9 +74,9 @@ export const MarkdownEditor = ({ value, onChange, overlayContent }: MarkdownEdit
|
||||
<div className={clsx(classes.MarkdownEditor, 'relative')}>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
height="70px"
|
||||
height={height}
|
||||
extensions={CODE_MIRROR_EXTENSIONS}
|
||||
className={classes.CERoot}
|
||||
className={clsx(classes.CERoot, className)}
|
||||
theme={oneDark}
|
||||
onChange={handleOnChange}
|
||||
placeholder="Start typing..."
|
||||
|
||||
@@ -8,8 +8,8 @@ import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager.ts';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { IconField } from 'primereact/iconfield';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MarkdownEditor } from '@/hooks/Mapper/components/mapInterface/components/MarkdownEditor';
|
||||
|
||||
interface SystemSettingsDialog {
|
||||
systemId: string;
|
||||
@@ -214,13 +214,9 @@ export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSe
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="username">Description</label>
|
||||
<InputTextarea
|
||||
autoResize
|
||||
rows={5}
|
||||
cols={30}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="h-[200px]">
|
||||
<MarkdownEditor value={description} onChange={e => setDescription(e)} height="180px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts';
|
||||
import { useMemo } from 'react';
|
||||
import { getSystemById, sortWHClasses } from '@/hooks/Mapper/helpers';
|
||||
import { InfoDrawer, WHClassView, WHEffectView } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { InfoDrawer, MarkdownTextViewer, WHClassView, WHEffectView } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic';
|
||||
|
||||
interface SystemInfoContentProps {
|
||||
@@ -51,7 +51,7 @@ export const SystemInfoContent = ({ systemId }: SystemInfoContentProps) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="break-words">{description}</div>
|
||||
<MarkdownTextViewer>{description}</MarkdownTextViewer>
|
||||
</InfoDrawer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,10 +30,14 @@ export const SystemStructures: React.FC = () => {
|
||||
|
||||
const processClipboard = useCallback(
|
||||
(text: string) => {
|
||||
if (!systemId) {
|
||||
console.warn('Cannot update structures: no system selected');
|
||||
return;
|
||||
}
|
||||
const updated = processSnippetText(text, structures);
|
||||
handleUpdateStructures(updated);
|
||||
},
|
||||
[structures, handleUpdateStructures],
|
||||
[systemId, structures, handleUpdateStructures],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
|
||||
@@ -56,6 +56,11 @@ export function useSystemStructures({ systemId, outCommand }: UseSystemStructure
|
||||
|
||||
const handleUpdateStructures = useCallback(
|
||||
async (newList: StructureItem[]) => {
|
||||
if (!systemId) {
|
||||
console.warn('Cannot update structures: systemId is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
const { added, updated, removed } = getActualStructures(structures, newList);
|
||||
|
||||
const sanitizedAdded = added.map(sanitizeIds);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
storedSettings: { settingsKills },
|
||||
} = useMapRootState();
|
||||
|
||||
const excludedSystems = useStableValue(settingsKills.excludedSystems);
|
||||
const excludedSystems = useStableValue(settingsKills.excludedSystems ?? []);
|
||||
|
||||
const effectiveSystemIds = useMemo(() => {
|
||||
if (showAllVisible) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { MenuItem } from 'primereact/menuitem';
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { CharacterActivityContent } from '@/hooks/Mapper/components/mapRootContent/components/CharacterActivity/CharacterActivityContent.tsx';
|
||||
|
||||
interface CharacterActivityProps {
|
||||
@@ -6,17 +9,69 @@ interface CharacterActivityProps {
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const periodOptions = [
|
||||
{ value: 30, label: '30 Days' },
|
||||
{ value: 365, label: '1 Year' },
|
||||
{ value: null, label: 'All Time' },
|
||||
];
|
||||
|
||||
export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(30);
|
||||
const menuRef = useRef<Menu>(null);
|
||||
|
||||
const handlePeriodChange = useCallback((days: number | null) => {
|
||||
setSelectedPeriod(days);
|
||||
}, []);
|
||||
|
||||
const menuItems: MenuItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Period',
|
||||
items: periodOptions.map(option => ({
|
||||
label: option.label,
|
||||
icon: selectedPeriod === option.value ? 'pi pi-check' : undefined,
|
||||
command: () => handlePeriodChange(option.value),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[selectedPeriod, handlePeriodChange],
|
||||
);
|
||||
|
||||
const selectedPeriodLabel = useMemo(
|
||||
() => periodOptions.find(opt => opt.value === selectedPeriod)?.label || 'All Time',
|
||||
[selectedPeriod],
|
||||
);
|
||||
|
||||
const headerIcons = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="p-dialog-header-icon p-link"
|
||||
onClick={e => menuRef.current?.toggle(e)}
|
||||
aria-label="Filter options"
|
||||
>
|
||||
<span className="pi pi-bars" />
|
||||
</button>
|
||||
<Menu model={menuItems} popup ref={menuRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="Character Activity"
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Character Activity</span>
|
||||
<span className="text-xs text-stone-400">({selectedPeriodLabel})</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
className="w-[550px] max-h-[90vh]"
|
||||
onHide={onHide}
|
||||
dismissableMask
|
||||
contentClassName="p-0 h-full flex flex-col"
|
||||
icons={headerIcons}
|
||||
>
|
||||
<CharacterActivityContent />
|
||||
<CharacterActivityContent selectedPeriod={selectedPeriod} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,16 +7,28 @@ import {
|
||||
} from '@/hooks/Mapper/components/mapRootContent/components/CharacterActivity/helpers.tsx';
|
||||
import { Column } from 'primereact/column';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useCharacterActivityHandlers } from '@/hooks/Mapper/components/mapRootContent/hooks/useCharacterActivityHandlers';
|
||||
|
||||
export const CharacterActivityContent = () => {
|
||||
interface CharacterActivityContentProps {
|
||||
selectedPeriod: number | null;
|
||||
}
|
||||
|
||||
export const CharacterActivityContent = ({ selectedPeriod }: CharacterActivityContentProps) => {
|
||||
const {
|
||||
data: { characterActivityData },
|
||||
} = useMapRootState();
|
||||
|
||||
const { handleShowActivity } = useCharacterActivityHandlers();
|
||||
|
||||
const activity = useMemo(() => characterActivityData?.activity || [], [characterActivityData]);
|
||||
const loading = useMemo(() => characterActivityData?.loading !== false, [characterActivityData]);
|
||||
|
||||
// Reload activity data when period changes
|
||||
useEffect(() => {
|
||||
handleShowActivity(selectedPeriod);
|
||||
}, [selectedPeriod, handleShowActivity]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
.SidebarOnTheMap {
|
||||
width: 400px;
|
||||
width: 500px;
|
||||
padding: 0 !important;
|
||||
|
||||
:global {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ConnectionType,
|
||||
OutCommand,
|
||||
Passage,
|
||||
PassageWithSourceTarget,
|
||||
SolarSystemConnection,
|
||||
} from '@/hooks/Mapper/types';
|
||||
import clsx from 'clsx';
|
||||
@@ -19,7 +20,7 @@ import { PassageCard } from './PassageCard';
|
||||
|
||||
const sortByDate = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime();
|
||||
|
||||
const itemTemplate = (item: Passage, options: VirtualScrollerTemplateOptions) => {
|
||||
const itemTemplate = (item: PassageWithSourceTarget, options: VirtualScrollerTemplateOptions) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.CharacterRow, 'w-full box-border', {
|
||||
@@ -35,7 +36,7 @@ const itemTemplate = (item: Passage, options: VirtualScrollerTemplateOptions) =>
|
||||
};
|
||||
|
||||
export interface ConnectionPassagesContentProps {
|
||||
passages: Passage[];
|
||||
passages: PassageWithSourceTarget[];
|
||||
}
|
||||
|
||||
export const ConnectionPassages = ({ passages = [] }: ConnectionPassagesContentProps) => {
|
||||
@@ -113,6 +114,20 @@ export const Connections = ({ selectedConnection, onHide }: OnTheMapProps) => {
|
||||
[outCommand],
|
||||
);
|
||||
|
||||
const preparedPassages = useMemo(() => {
|
||||
if (!cnInfo) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return passages
|
||||
.sort((a, b) => sortByDate(b.inserted_at, a.inserted_at))
|
||||
.map<PassageWithSourceTarget>(x => ({
|
||||
...x,
|
||||
source: x.from ? cnInfo.target : cnInfo.source,
|
||||
target: x.from ? cnInfo.source : cnInfo.target,
|
||||
}));
|
||||
}, [cnInfo, passages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConnection) {
|
||||
return;
|
||||
@@ -145,12 +160,14 @@ export const Connections = ({ selectedConnection, onHide }: OnTheMapProps) => {
|
||||
<InfoDrawer title="Connection" rightSide>
|
||||
<div className="flex justify-end gap-2 items-center">
|
||||
<SystemView
|
||||
showCustomName
|
||||
systemId={cnInfo.source}
|
||||
className={clsx(classes.InfoTextSize, 'select-none text-center')}
|
||||
hideRegion
|
||||
/>
|
||||
<span className="pi pi-angle-double-right text-stone-500 text-[15px]"></span>
|
||||
<SystemView
|
||||
showCustomName
|
||||
systemId={cnInfo.target}
|
||||
className={clsx(classes.InfoTextSize, 'select-none text-center')}
|
||||
hideRegion
|
||||
@@ -184,7 +201,7 @@ export const Connections = ({ selectedConnection, onHide }: OnTheMapProps) => {
|
||||
{/* separator */}
|
||||
<div className="w-full h-px bg-neutral-800 px-0.5"></div>
|
||||
|
||||
<ConnectionPassages passages={passages} />
|
||||
<ConnectionPassages passages={preparedPassages} />
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
&.ThreeColumns {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
&.FourColumns {
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.CardBorderLeftIsOwn {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import classes from './PassageCard.module.scss';
|
||||
import { Passage } from '@/hooks/Mapper/types';
|
||||
import { TimeAgo } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { PassageWithSourceTarget } from '@/hooks/Mapper/types';
|
||||
import { SystemView, TimeAgo, TooltipPosition, WdImgButton } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
import { kgToTons } from '@/hooks/Mapper/utils/kgToTons.ts';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { ZKB_ICON } from '@/hooks/Mapper/icons';
|
||||
import { charEveWhoLink, charZKBLink } from '@/hooks/Mapper/helpers/linkHelpers.ts';
|
||||
|
||||
type PassageCardType = {
|
||||
// compact?: boolean;
|
||||
showShipName?: boolean;
|
||||
// showSystem?: boolean;
|
||||
// useSystemsCache?: boolean;
|
||||
} & Passage;
|
||||
} & PassageWithSourceTarget;
|
||||
|
||||
const SHIP_NAME_RX = /u'|'/g;
|
||||
export const getShipName = (name: string) => {
|
||||
@@ -25,7 +27,7 @@ export const getShipName = (name: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardType) => {
|
||||
export const PassageCard = ({ inserted_at, character: char, ship, source, target, from }: PassageCardType) => {
|
||||
const isOwn = false;
|
||||
|
||||
const insertedAt = useMemo(() => {
|
||||
@@ -33,11 +35,46 @@ export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardT
|
||||
return date.toLocaleString();
|
||||
}, [inserted_at]);
|
||||
|
||||
const handleOpenZKB = useCallback(() => window.open(charZKBLink(char.eve_id), '_blank'), [char]);
|
||||
const handleOpenEveWho = useCallback(() => window.open(charEveWhoLink(char.eve_id), '_blank'), [char]);
|
||||
|
||||
return (
|
||||
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')}>
|
||||
<div className="flex flex-col justify-between px-2 py-1 gap-1">
|
||||
{/*here icon and other*/}
|
||||
<div className={clsx(classes.CharRow, classes.ThreeColumns)}>
|
||||
<div className={clsx(classes.CharRow, classes.FourColumns)}>
|
||||
<WdTooltipWrapper
|
||||
position={TooltipPosition.top}
|
||||
content={
|
||||
<div className="flex justify-between gap-2 items-center">
|
||||
<SystemView
|
||||
showCustomName
|
||||
systemId={source}
|
||||
className="select-none text-center !text-[12px]"
|
||||
hideRegion
|
||||
/>
|
||||
<span className="pi pi-angle-double-right text-stone-500 text-[15px]"></span>
|
||||
<SystemView
|
||||
showCustomName
|
||||
systemId={target}
|
||||
className="select-none text-center !text-[12px]"
|
||||
hideRegion
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-all transform ease-in duration-200',
|
||||
'pi text-stone-500 text-[15px] w-[35px] h-[33px] !flex items-center justify-center border rounded-[6px]',
|
||||
{
|
||||
['pi-angle-double-right !text-orange-400 border-orange-400 hover:bg-orange-400/30']: from,
|
||||
['pi-angle-double-left !text-stone-500/70 border-stone-500/70 hover:bg-stone-500/30']: !from,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</WdTooltipWrapper>
|
||||
|
||||
{/*portrait*/}
|
||||
<span
|
||||
className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')}
|
||||
@@ -49,7 +86,7 @@ export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardT
|
||||
{/*here name and ship name*/}
|
||||
<div className="grid gap-1 justify-between grid-cols-[max-content_1fr]">
|
||||
{/*char name*/}
|
||||
<div className="grid gap-1 grid-cols-[auto_1px_1fr]">
|
||||
<div className="grid gap-1 grid-cols-[auto_1px_1fr_auto]">
|
||||
<span
|
||||
className={clsx(classes.MaxWidth, 'text-ellipsis overflow-hidden whitespace-nowrap', {
|
||||
[classes.CardBorderLeftIsOwn]: isOwn,
|
||||
@@ -62,6 +99,21 @@ export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardT
|
||||
<div className="h-3 border-r border-neutral-500 my-0.5"></div>
|
||||
{char.alliance_ticker && <span className="text-neutral-400">{char.alliance_ticker}</span>}
|
||||
{!char.alliance_ticker && <span className="text-neutral-400">{char.corporation_ticker}</span>}
|
||||
|
||||
<div className={clsx('flex gap-1 items-center h-full ml-[2px]')}>
|
||||
<WdImgButton
|
||||
width={16}
|
||||
height={16}
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open zkillboard' }}
|
||||
source={ZKB_ICON}
|
||||
onClick={handleOpenZKB}
|
||||
/>
|
||||
<WdImgButton
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open Eve Who' }}
|
||||
className={clsx('pi pi-user', '!text-[12px] relative top-[-1px]')}
|
||||
onClick={handleOpenEveWho}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*ship name*/}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use "sass:color";
|
||||
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
|
||||
@import '@/hooks/Mapper/components/map/styles/solar-system-node';
|
||||
@use '@/hooks/Mapper/components/map/styles/solar-system-node' as v;
|
||||
|
||||
:root {
|
||||
--rf-has-user-characters: #ffc75d;
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
border-color: v.$pastel-pink;
|
||||
box-shadow: 0 0 10px #9a1af1c2;
|
||||
}
|
||||
|
||||
@@ -122,11 +122,11 @@
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
|
||||
border-color: $neon-color-1;
|
||||
border-color: v.$neon-color-1;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
$neon-color-3 0px,
|
||||
$neon-color-3 8px,
|
||||
v.$neon-color-3 0px,
|
||||
v.$neon-color-3 8px,
|
||||
transparent 8px,
|
||||
transparent 21px
|
||||
);
|
||||
@@ -152,7 +152,7 @@
|
||||
&.eve-system-status-lookingFor {
|
||||
background-image: linear-gradient(275deg, #45ff8f2f, #457fff2f);
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
border-color: v.$pastel-pink;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,17 +23,17 @@ export const useCharacterActivityHandlers = () => {
|
||||
/**
|
||||
* Handle showing the character activity dialog
|
||||
*/
|
||||
const handleShowActivity = useCallback(() => {
|
||||
const handleShowActivity = useCallback((days?: number | null) => {
|
||||
// Update local state to show the dialog
|
||||
update(state => ({
|
||||
...state,
|
||||
showCharacterActivity: true,
|
||||
}));
|
||||
|
||||
// Send the command to the server
|
||||
// Send the command to the server with optional days parameter
|
||||
outCommand({
|
||||
type: OutCommand.showActivity,
|
||||
data: {},
|
||||
data: days !== undefined ? { days } : {},
|
||||
});
|
||||
}, [outCommand, update]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
WdEveEntityPortrait,
|
||||
WdEveEntityPortraitSize,
|
||||
WdEveEntityPortraitType,
|
||||
WdImgButton,
|
||||
WdTooltipWrapper,
|
||||
} from '@/hooks/Mapper/components/ui-kit';
|
||||
import { SystemView } from '@/hooks/Mapper/components/ui-kit/SystemView';
|
||||
@@ -14,6 +15,8 @@ import { Commands } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import classes from './CharacterCard.module.scss';
|
||||
import { ZKB_ICON } from '@/hooks/Mapper/icons';
|
||||
import { charEveWhoLink, charZKBLink } from '@/hooks/Mapper/helpers/linkHelpers.ts';
|
||||
|
||||
export type CharacterCardProps = {
|
||||
compact?: boolean;
|
||||
@@ -66,6 +69,9 @@ export const CharacterCard = ({
|
||||
const shipType = char.ship?.ship_type_info?.name;
|
||||
const locationShown = showSystem && char.location?.solar_system_id;
|
||||
|
||||
const handleOpenZKB = useCallback(() => window.open(charZKBLink(char.eve_id), '_blank'), [char]);
|
||||
const handleOpenEveWho = useCallback(() => window.open(charEveWhoLink(char.eve_id), '_blank'), [char]);
|
||||
|
||||
// INFO: Simple mode show only name and icon of ally/corp. By default it compact view
|
||||
if (simpleMode) {
|
||||
return (
|
||||
@@ -244,7 +250,24 @@ export const CharacterCard = ({
|
||||
{char.name}
|
||||
</span>
|
||||
{showTicker && <span className="flex-shrink-0 text-gray-400 ml-1">[{tickerText}]</span>}
|
||||
|
||||
<div className={clsx('flex gap-1 items-center h-full ml-[6px]')}>
|
||||
<WdImgButton
|
||||
width={16}
|
||||
height={16}
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open zkillboard' }}
|
||||
source={ZKB_ICON}
|
||||
onClick={handleOpenZKB}
|
||||
className="min-w-[16px]"
|
||||
/>
|
||||
<WdImgButton
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open Eve Who' }}
|
||||
className={clsx('pi pi-user', '!text-[12px] relative top-[-1px]')}
|
||||
onClick={handleOpenEveWho}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locationShown ? (
|
||||
<div className="text-gray-300 text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<SystemView
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
.MarkdownCommentRoot {
|
||||
border-left-width: 3px;
|
||||
|
||||
.MarkdownTextViewer {
|
||||
@apply text-[12px] leading-[1.2] text-stone-300 break-words;
|
||||
@apply bg-gradient-to-r from-stone-600/40 via-stone-600/10 to-stone-600/0;
|
||||
|
||||
.h1 {
|
||||
@apply text-[12px] font-normal m-0 p-0 border-none break-words whitespace-normal;
|
||||
@@ -56,6 +53,10 @@
|
||||
@apply font-bold text-green-400 break-words whitespace-normal;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
i, em {
|
||||
@apply italic text-pink-400 break-words whitespace-normal;
|
||||
}
|
||||
@@ -2,10 +2,16 @@ import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
|
||||
import classes from './MarkdownTextViewer.module.scss';
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks];
|
||||
|
||||
type MarkdownTextViewerProps = { children: string };
|
||||
|
||||
export const MarkdownTextViewer = ({ children }: MarkdownTextViewerProps) => {
|
||||
return <Markdown remarkPlugins={REMARK_PLUGINS}>{children}</Markdown>;
|
||||
return (
|
||||
<div className={classes.MarkdownTextViewer}>
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS}>{children}</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
2
assets/js/hooks/Mapper/helpers/linkHelpers.ts
Normal file
2
assets/js/hooks/Mapper/helpers/linkHelpers.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const charZKBLink = (characterId: string) => `https://zkillboard.com/character/${characterId}/`;
|
||||
export const charEveWhoLink = (characterId: string) => `https://evewho.com/character/${characterId}`;
|
||||
@@ -12,7 +12,7 @@ export const useCommandComments = () => {
|
||||
}, []);
|
||||
|
||||
const removeComment = useCallback((data: CommandCommentRemoved) => {
|
||||
ref.current.removeComment(data.solarSystemId.toString(), data.commentId);
|
||||
ref.current.removeComment(data.solarSystemId, data.commentId);
|
||||
}, []);
|
||||
|
||||
return { addComment, removeComment };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import {
|
||||
CommandCharacterAdded,
|
||||
CommandCharacterRemoved,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
CommandCharacterUpdated,
|
||||
CommandPresentCharacters,
|
||||
} from '@/hooks/Mapper/types';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export const useCommandsCharacters = () => {
|
||||
const { update } = useMapRootState();
|
||||
@@ -14,8 +14,27 @@ export const useCommandsCharacters = () => {
|
||||
const ref = useRef({ update });
|
||||
ref.current = { update };
|
||||
|
||||
const charactersUpdated = useCallback((characters: CommandCharactersUpdated) => {
|
||||
ref.current.update(() => ({ characters: characters.slice() }));
|
||||
const charactersUpdated = useCallback((updatedCharacters: CommandCharactersUpdated) => {
|
||||
ref.current.update(state => {
|
||||
const existing = state.characters ?? [];
|
||||
// Put updatedCharacters into a map keyed by ID
|
||||
const updatedMap = new Map(updatedCharacters.map(c => [c.eve_id, c]));
|
||||
|
||||
// 1. Update existing characters when possible
|
||||
const merged = existing.map(character => {
|
||||
const updated = updatedMap.get(character.eve_id);
|
||||
if (updated) {
|
||||
updatedMap.delete(character.eve_id); // Mark as processed
|
||||
return { ...character, ...updated };
|
||||
}
|
||||
return character;
|
||||
});
|
||||
|
||||
// 2. Any remaining items in updatedMap are NEW characters → add them
|
||||
const newCharacters = Array.from(updatedMap.values());
|
||||
|
||||
return { characters: [...merged, ...newCharacters] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const characterAdded = useCallback((value: CommandCharacterAdded) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { CommentSystem, CommentType, OutCommand, OutCommandHandler, UseCommentsData } from '@/hooks/Mapper/types';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface UseCommentsProps {
|
||||
outCommand: OutCommandHandler;
|
||||
@@ -8,12 +8,12 @@ interface UseCommentsProps {
|
||||
export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData => {
|
||||
const [lastUpdateKey, setLastUpdateKey] = useState(0);
|
||||
|
||||
const commentBySystemsRef = useRef<Map<string, CommentSystem>>(new Map());
|
||||
const commentBySystemsRef = useRef<Map<number, CommentSystem>>(new Map());
|
||||
|
||||
const ref = useRef({ outCommand });
|
||||
ref.current = { outCommand };
|
||||
|
||||
const loadComments = useCallback(async (systemId: string) => {
|
||||
const loadComments = useCallback(async (systemId: number) => {
|
||||
let cSystem = commentBySystemsRef.current.get(systemId);
|
||||
if (cSystem?.loading || cSystem?.loaded) {
|
||||
return;
|
||||
@@ -45,7 +45,7 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
|
||||
setLastUpdateKey(x => x + 1);
|
||||
}, []);
|
||||
|
||||
const addComment = useCallback((systemId: string, comment: CommentType) => {
|
||||
const addComment = useCallback((systemId: number, comment: CommentType) => {
|
||||
const cSystem = commentBySystemsRef.current.get(systemId);
|
||||
if (cSystem) {
|
||||
cSystem.comments.push(comment);
|
||||
@@ -61,8 +61,9 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
|
||||
setLastUpdateKey(x => x + 1);
|
||||
}, []);
|
||||
|
||||
const removeComment = useCallback((systemId: string, commentId: string) => {
|
||||
const removeComment = useCallback((systemId: number, commentId: string) => {
|
||||
const cSystem = commentBySystemsRef.current.get(systemId);
|
||||
console.log('cSystem', cSystem);
|
||||
if (!cSystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export type CharacterTypeRaw = {
|
||||
corporation_id: number;
|
||||
corporation_name: string;
|
||||
corporation_ticker: string;
|
||||
tracking_paused: boolean;
|
||||
};
|
||||
|
||||
export interface TrackingCharacter {
|
||||
@@ -69,4 +68,5 @@ export interface ActivitySummary {
|
||||
passages: number;
|
||||
connections: number;
|
||||
signatures: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ export type CommentSystem = {
|
||||
};
|
||||
|
||||
export interface UseCommentsData {
|
||||
loadComments: (systemId: string) => Promise<void>;
|
||||
addComment: (systemId: string, comment: CommentType) => void;
|
||||
removeComment: (systemId: string, commentId: string) => void;
|
||||
comments: Map<string, CommentSystem>;
|
||||
loadComments: (systemId: number) => Promise<void>;
|
||||
addComment: (systemId: number, comment: CommentType) => void;
|
||||
removeComment: (systemId: number, commentId: string) => void;
|
||||
comments: Map<number, CommentSystem>;
|
||||
lastUpdateKey: number;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ export type PassageLimitedCharacterType = Pick<
|
||||
>;
|
||||
|
||||
export type Passage = {
|
||||
from: boolean;
|
||||
inserted_at: string; // Date
|
||||
ship: ShipTypeRaw;
|
||||
character: PassageLimitedCharacterType;
|
||||
};
|
||||
|
||||
export type PassageWithSourceTarget = {
|
||||
source: string;
|
||||
target: string;
|
||||
} & Passage;
|
||||
|
||||
export type ConnectionInfoOutput = {
|
||||
marl_eol_time: string;
|
||||
};
|
||||
|
||||
@@ -131,7 +131,7 @@ export type CommandLinkSignatureToSystem = {
|
||||
};
|
||||
export type CommandLinkSignaturesUpdated = number;
|
||||
export type CommandCommentAdd = {
|
||||
solarSystemId: string;
|
||||
solarSystemId: number;
|
||||
comment: CommentType;
|
||||
};
|
||||
export type CommandCommentRemoved = {
|
||||
|
||||
@@ -12,11 +12,11 @@ const animateBg = function (bgCanvas) {
|
||||
*/
|
||||
const randomInRange = (max, min) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const BASE_SIZE = 1;
|
||||
const VELOCITY_INC = 1.01;
|
||||
const VELOCITY_INC = 1.002;
|
||||
const VELOCITY_INIT_INC = 0.525;
|
||||
const JUMP_VELOCITY_INC = 0.55;
|
||||
const JUMP_SIZE_INC = 1.15;
|
||||
const SIZE_INC = 1.01;
|
||||
const SIZE_INC = 1.002;
|
||||
const RAD = Math.PI / 180;
|
||||
const WARP_COLORS = [
|
||||
[197, 239, 247],
|
||||
|
||||
@@ -27,11 +27,7 @@ config :wanderer_app,
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ddrt: WandererApp.Map.CacheRTree,
|
||||
logger: Logger,
|
||||
pubsub_client: Phoenix.PubSub,
|
||||
wanderer_kills_base_url:
|
||||
System.get_env("WANDERER_KILLS_BASE_URL", "ws://host.docker.internal:4004"),
|
||||
wanderer_kills_service_enabled:
|
||||
System.get_env("WANDERER_KILLS_SERVICE_ENABLED", "false") == "true"
|
||||
pubsub_client: Phoenix.PubSub
|
||||
|
||||
config :wanderer_app, WandererAppWeb.Endpoint,
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
|
||||
@@ -4,7 +4,7 @@ import Config
|
||||
config :wanderer_app, WandererApp.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: System.get_env("DB_HOST", "localhost"),
|
||||
hostname: "localhost",
|
||||
database: "wanderer_dev",
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
@@ -63,6 +63,7 @@ config :wanderer_app, WandererAppWeb.Endpoint,
|
||||
]
|
||||
|
||||
config :wanderer_app,
|
||||
environment: :dev,
|
||||
dev_routes: true
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Config
|
||||
|
||||
# Set environment at compile time for modules using Application.compile_env
|
||||
config :wanderer_app, environment: :prod
|
||||
|
||||
# Note we also include the path to a cache manifest
|
||||
# containing the digested version of static files. This
|
||||
# manifest is generated by the `mix assets.deploy` task,
|
||||
|
||||
@@ -177,7 +177,34 @@ config :wanderer_app,
|
||||
],
|
||||
extra_characters_50: map_subscription_extra_characters_50_price,
|
||||
extra_hubs_10: map_subscription_extra_hubs_10_price
|
||||
}
|
||||
},
|
||||
# Finch pool configuration - separate pools for different services
|
||||
# ESI Character Tracking pool - high capacity for bulk character operations
|
||||
# With 30+ TrackerPools × ~100 concurrent tasks, need large pool
|
||||
finch_esi_character_pool_size:
|
||||
System.get_env("WANDERER_FINCH_ESI_CHARACTER_POOL_SIZE", "200") |> String.to_integer(),
|
||||
finch_esi_character_pool_count:
|
||||
System.get_env("WANDERER_FINCH_ESI_CHARACTER_POOL_COUNT", "4") |> String.to_integer(),
|
||||
# ESI General pool - standard capacity for general ESI operations
|
||||
finch_esi_general_pool_size:
|
||||
System.get_env("WANDERER_FINCH_ESI_GENERAL_POOL_SIZE", "50") |> String.to_integer(),
|
||||
finch_esi_general_pool_count:
|
||||
System.get_env("WANDERER_FINCH_ESI_GENERAL_POOL_COUNT", "4") |> String.to_integer(),
|
||||
# Webhooks pool - isolated from ESI rate limits
|
||||
finch_webhooks_pool_size:
|
||||
System.get_env("WANDERER_FINCH_WEBHOOKS_POOL_SIZE", "25") |> String.to_integer(),
|
||||
finch_webhooks_pool_count:
|
||||
System.get_env("WANDERER_FINCH_WEBHOOKS_POOL_COUNT", "2") |> String.to_integer(),
|
||||
# Default pool - everything else (email, license manager, etc.)
|
||||
finch_default_pool_size:
|
||||
System.get_env("WANDERER_FINCH_DEFAULT_POOL_SIZE", "25") |> String.to_integer(),
|
||||
finch_default_pool_count:
|
||||
System.get_env("WANDERER_FINCH_DEFAULT_POOL_COUNT", "2") |> String.to_integer(),
|
||||
# Character tracker concurrency settings
|
||||
# Location updates need high concurrency for <2s response with 3000+ characters
|
||||
location_concurrency:
|
||||
System.get_env("WANDERER_LOCATION_CONCURRENCY", "#{System.schedulers_online() * 12}")
|
||||
|> String.to_integer()
|
||||
|
||||
config :ueberauth, Ueberauth,
|
||||
providers: [
|
||||
@@ -405,7 +432,7 @@ config :wanderer_app, :license_manager,
|
||||
config :wanderer_app, :sse,
|
||||
enabled:
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "true")
|
||||
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "false")
|
||||
|> String.to_existing_atom(),
|
||||
max_connections_total:
|
||||
config_dir |> get_int_from_path_or_env("WANDERER_SSE_MAX_CONNECTIONS", 1000),
|
||||
@@ -420,6 +447,6 @@ config :wanderer_app, :sse,
|
||||
config :wanderer_app, :external_events,
|
||||
webhooks_enabled:
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "true")
|
||||
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "false")
|
||||
|> String.to_existing_atom(),
|
||||
webhook_timeout_ms: config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000)
|
||||
|
||||
@@ -24,7 +24,11 @@ config :wanderer_app,
|
||||
pubsub_client: Test.PubSubMock,
|
||||
cached_info: WandererApp.CachedInfo.Mock,
|
||||
character_api_disabled: false,
|
||||
environment: :test
|
||||
environment: :test,
|
||||
map_subscriptions_enabled: false,
|
||||
wanderer_kills_service_enabled: false,
|
||||
sse: [enabled: false],
|
||||
external_events: [webhooks_enabled: false]
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
|
||||
@@ -60,19 +60,17 @@ defmodule WandererApp.Api.AccessList do
|
||||
# Added :api_key to the accepted attributes
|
||||
accept [:name, :description, :owner_id, :api_key]
|
||||
primary?(true)
|
||||
|
||||
argument :owner_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:owner_id, :owner, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:name, :description, :owner_id, :api_key]
|
||||
primary?(true)
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :assign_owner do
|
||||
accept [:owner_id]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -53,7 +53,11 @@ defmodule WandererApp.Api.AccessListMember do
|
||||
:role
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :read_by_access_list do
|
||||
argument(:access_list_id, :string, allow_nil?: false)
|
||||
@@ -67,12 +71,14 @@ defmodule WandererApp.Api.AccessListMember do
|
||||
|
||||
update :block do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:blocked, true))
|
||||
end
|
||||
|
||||
update :unblock do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:blocked, false))
|
||||
end
|
||||
|
||||
80
lib/wanderer_app/api/actor_helpers.ex
Normal file
80
lib/wanderer_app/api/actor_helpers.ex
Normal file
@@ -0,0 +1,80 @@
|
||||
defmodule WandererApp.Api.ActorHelpers do
|
||||
@moduledoc """
|
||||
Utilities for extracting actor information from Ash contexts.
|
||||
|
||||
Provides helper functions for working with ActorWithMap and extracting
|
||||
user, map, and character information from various context formats.
|
||||
"""
|
||||
|
||||
alias WandererApp.Api.ActorWithMap
|
||||
|
||||
@doc """
|
||||
Extract map from actor or context.
|
||||
|
||||
Handles various context formats:
|
||||
- Direct ActorWithMap struct
|
||||
- Context map with :actor key
|
||||
- Context map with :map key
|
||||
- Ash.Resource.Change.Context struct
|
||||
"""
|
||||
def get_map(%{actor: %ActorWithMap{map: %{} = map}}), do: map
|
||||
def get_map(%{map: %{} = map}), do: map
|
||||
|
||||
# Handle Ash.Resource.Change.Context struct
|
||||
def get_map(%Ash.Resource.Change.Context{actor: %ActorWithMap{map: %{} = map}}), do: map
|
||||
def get_map(%Ash.Resource.Change.Context{actor: _}), do: nil
|
||||
|
||||
def get_map(context) when is_map(context) do
|
||||
# For plain maps, check private.actor
|
||||
with private when is_map(private) <- Map.get(context, :private),
|
||||
%ActorWithMap{map: %{} = map} <- Map.get(private, :actor) do
|
||||
map
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def get_map(_), do: nil
|
||||
|
||||
@doc """
|
||||
Extract user from actor.
|
||||
|
||||
Handles:
|
||||
- ActorWithMap struct
|
||||
- Direct user struct with :id field
|
||||
"""
|
||||
def get_user(%ActorWithMap{user: user}), do: user
|
||||
def get_user(%{id: _} = user), do: user
|
||||
def get_user(_), do: nil
|
||||
|
||||
@doc """
|
||||
Get character IDs for the actor.
|
||||
|
||||
Used for ACL filtering to determine which resources the user can access.
|
||||
Returns {:ok, list} or {:ok, []} if no characters found.
|
||||
"""
|
||||
def get_character_ids(%ActorWithMap{user: user}), do: get_character_ids(user)
|
||||
|
||||
def get_character_ids(%{characters: characters}) when is_list(characters) do
|
||||
{:ok, Enum.map(characters, & &1.id)}
|
||||
end
|
||||
|
||||
def get_character_ids(%{characters: %Ecto.Association.NotLoaded{}, id: user_id}) do
|
||||
# Load characters from database
|
||||
load_characters_by_id(user_id)
|
||||
end
|
||||
|
||||
def get_character_ids(%{id: user_id}) do
|
||||
# Fallback: load user with characters
|
||||
load_characters_by_id(user_id)
|
||||
end
|
||||
|
||||
def get_character_ids(_), do: {:ok, []}
|
||||
|
||||
defp load_characters_by_id(user_id) do
|
||||
case WandererApp.Api.User.by_id(user_id, load: [:characters]) do
|
||||
{:ok, user} -> {:ok, Enum.map(user.characters, & &1.id)}
|
||||
_ -> {:ok, []}
|
||||
end
|
||||
end
|
||||
end
|
||||
15
lib/wanderer_app/api/actor_with_map.ex
Normal file
15
lib/wanderer_app/api/actor_with_map.ex
Normal file
@@ -0,0 +1,15 @@
|
||||
defmodule WandererApp.Api.ActorWithMap do
|
||||
@moduledoc """
|
||||
Wraps a user and map together as an actor for token-based authentication.
|
||||
|
||||
When API requests use Bearer token auth, the token identifies both the user
|
||||
(map owner) and the map. This struct allows passing both through Ash's actor system.
|
||||
"""
|
||||
|
||||
@enforce_keys [:user, :map]
|
||||
defstruct [:user, :map]
|
||||
|
||||
def new(user, map) do
|
||||
%__MODULE__{user: user, map: map}
|
||||
end
|
||||
end
|
||||
39
lib/wanderer_app/api/changes/inject_map_from_actor.ex
Normal file
39
lib/wanderer_app/api/changes/inject_map_from_actor.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule WandererApp.Api.Changes.InjectMapFromActor do
|
||||
@moduledoc """
|
||||
Ash change that injects map_id from the authenticated actor.
|
||||
|
||||
For token-based auth, the map is determined by the API token.
|
||||
This change automatically sets map_id, so clients don't need to provide it.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias WandererApp.Api.ActorHelpers
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
case ActorHelpers.get_map(context) do
|
||||
%{id: map_id} ->
|
||||
Ash.Changeset.force_change_attribute(changeset, :map_id, map_id)
|
||||
|
||||
_other ->
|
||||
# nil or unexpected return shape - check for direct map_id
|
||||
# Check params (input), arguments, and attributes (in that order)
|
||||
map_id = Map.get(changeset.params, :map_id) ||
|
||||
Ash.Changeset.get_argument(changeset, :map_id) ||
|
||||
Ash.Changeset.get_attribute(changeset, :map_id)
|
||||
|
||||
case map_id do
|
||||
nil ->
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :map_id,
|
||||
message: "map_id is required (provide via token or attribute)"
|
||||
)
|
||||
|
||||
_map_id ->
|
||||
# map_id provided directly (internal calls, tests)
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,25 @@
|
||||
defmodule WandererApp.Api.Changes.SlugifyName do
|
||||
@moduledoc """
|
||||
Ensures map slugs are unique by:
|
||||
1. Slugifying the provided slug/name
|
||||
2. Checking for existing slugs (optimization)
|
||||
3. Finding next available slug with numeric suffix if needed
|
||||
4. Relying on database unique constraint as final arbiter
|
||||
|
||||
Race Condition Mitigation:
|
||||
- Optimistic check reduces DB roundtrips for most cases
|
||||
- Database unique index ensures no duplicates slip through
|
||||
- Proper error messages for constraint violations
|
||||
- Telemetry events for monitoring conflicts
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Changeset
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
# Maximum number of attempts to find a unique slug
|
||||
@max_attempts 100
|
||||
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
@@ -26,7 +43,7 @@ defmodule WandererApp.Api.Changes.SlugifyName do
|
||||
# Get the current record ID if this is an update operation
|
||||
current_id = Changeset.get_attribute(changeset, :id)
|
||||
|
||||
# Check if the base slug is available
|
||||
# Check if the base slug is available (optimization to avoid numeric suffixes when possible)
|
||||
if slug_available?(base_slug, current_id) do
|
||||
base_slug
|
||||
else
|
||||
@@ -35,16 +52,44 @@ defmodule WandererApp.Api.Changes.SlugifyName do
|
||||
end
|
||||
end
|
||||
|
||||
defp find_available_slug(base_slug, current_id, n) do
|
||||
defp find_available_slug(base_slug, current_id, n) when n <= @max_attempts do
|
||||
candidate_slug = "#{base_slug}-#{n}"
|
||||
|
||||
if slug_available?(candidate_slug, current_id) do
|
||||
# Emit telemetry when we had to use a suffix (indicates potential conflict)
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :slug_suffix_used],
|
||||
%{suffix_number: n},
|
||||
%{base_slug: base_slug, final_slug: candidate_slug}
|
||||
)
|
||||
|
||||
candidate_slug
|
||||
else
|
||||
find_available_slug(base_slug, current_id, n + 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp find_available_slug(base_slug, _current_id, n) when n > @max_attempts do
|
||||
# Fallback: use timestamp suffix if we've tried too many numeric suffixes
|
||||
# This handles edge cases where many maps have similar names
|
||||
timestamp = System.system_time(:millisecond)
|
||||
fallback_slug = "#{base_slug}-#{timestamp}"
|
||||
|
||||
Logger.warning(
|
||||
"Slug generation exceeded #{@max_attempts} attempts for '#{base_slug}', using timestamp fallback",
|
||||
base_slug: base_slug,
|
||||
fallback_slug: fallback_slug
|
||||
)
|
||||
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :slug_fallback_used],
|
||||
%{attempts: n},
|
||||
%{base_slug: base_slug, fallback_slug: fallback_slug}
|
||||
)
|
||||
|
||||
fallback_slug
|
||||
end
|
||||
|
||||
defp slug_available?(slug, current_id) do
|
||||
query =
|
||||
WandererApp.Api.Map
|
||||
@@ -60,9 +105,20 @@ defmodule WandererApp.Api.Changes.SlugifyName do
|
||||
|> Ash.Query.limit(1)
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, []} -> true
|
||||
{:ok, _} -> false
|
||||
{:error, _} -> false
|
||||
{:ok, []} ->
|
||||
true
|
||||
|
||||
{:ok, _existing} ->
|
||||
false
|
||||
|
||||
{:error, error} ->
|
||||
# Log error but be conservative - assume slug is not available
|
||||
Logger.warning("Error checking slug availability",
|
||||
slug: slug,
|
||||
error: inspect(error)
|
||||
)
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,11 +69,6 @@ defmodule WandererApp.Api.Character do
|
||||
filter(expr(user_id == ^arg(:user_id) and deleted == false))
|
||||
end
|
||||
|
||||
read :available_by_map do
|
||||
argument(:map_id, :uuid, allow_nil?: false)
|
||||
filter(expr(user_id == ^arg(:user_id) and deleted == false))
|
||||
end
|
||||
|
||||
read :last_active do
|
||||
argument(:from, :utc_datetime, allow_nil?: false)
|
||||
|
||||
@@ -100,6 +95,7 @@ defmodule WandererApp.Api.Character do
|
||||
|
||||
update :mark_as_deleted do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(atomic_update(:deleted, true))
|
||||
change(atomic_update(:user_id, nil))
|
||||
@@ -107,6 +103,7 @@ defmodule WandererApp.Api.Character do
|
||||
|
||||
update :update_online do
|
||||
accept([:online])
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_location do
|
||||
|
||||
@@ -33,7 +33,11 @@ defmodule WandererApp.Api.CorpWalletTransaction do
|
||||
:ref_type
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :new do
|
||||
accept [
|
||||
|
||||
@@ -36,7 +36,11 @@ defmodule WandererApp.Api.License do
|
||||
:expire_at
|
||||
]
|
||||
|
||||
defaults [:read, :update, :destroy]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
@@ -58,12 +62,14 @@ defmodule WandererApp.Api.License do
|
||||
|
||||
update :invalidate do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:is_valid, false))
|
||||
end
|
||||
|
||||
update :set_valid do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:is_valid, true))
|
||||
end
|
||||
|
||||
@@ -8,6 +8,8 @@ defmodule WandererApp.Api.Map do
|
||||
|
||||
alias Ash.Resource.Change.Builtins
|
||||
|
||||
require Logger
|
||||
|
||||
postgres do
|
||||
repo(WandererApp.Repo)
|
||||
table("maps_v1")
|
||||
@@ -31,7 +33,7 @@ defmodule WandererApp.Api.Map do
|
||||
routes do
|
||||
base("/maps")
|
||||
get(:by_slug, route: "/:slug")
|
||||
index :read
|
||||
# index :read
|
||||
post(:new)
|
||||
patch(:update)
|
||||
delete(:destroy)
|
||||
@@ -44,6 +46,7 @@ defmodule WandererApp.Api.Map do
|
||||
code_interface do
|
||||
define(:available, action: :available)
|
||||
define(:get_map_by_slug, action: :by_slug, args: [:slug])
|
||||
define(:by_api_key, action: :by_api_key, args: [:api_key])
|
||||
define(:new, action: :new)
|
||||
define(:create, action: :create)
|
||||
define(:update, action: :update)
|
||||
@@ -54,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],
|
||||
@@ -90,22 +94,34 @@ defmodule WandererApp.Api.Map do
|
||||
filter expr(slug == ^arg(:slug))
|
||||
end
|
||||
|
||||
read :by_api_key do
|
||||
get? true
|
||||
argument :api_key, :string, allow_nil?: false
|
||||
|
||||
prepare WandererApp.Api.Preparations.SecureApiKeyLookup
|
||||
end
|
||||
|
||||
read :available do
|
||||
prepare WandererApp.Api.Preparations.FilterMapsByRoles
|
||||
end
|
||||
|
||||
create :new do
|
||||
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
|
||||
primary?(true)
|
||||
accept [
|
||||
:name,
|
||||
:slug,
|
||||
:description,
|
||||
:scope,
|
||||
:only_tracked_characters,
|
||||
:owner_id,
|
||||
:sse_enabled
|
||||
]
|
||||
|
||||
argument :owner_id, :uuid, allow_nil?: false
|
||||
primary?(true)
|
||||
argument :create_default_acl, :boolean, allow_nil?: true
|
||||
argument :acls, {:array, :uuid}, allow_nil?: true
|
||||
argument :acls_text_input, :string, allow_nil?: true
|
||||
argument :scope_text_input, :string, allow_nil?: true
|
||||
argument :acls_empty_selection, :string, allow_nil?: true
|
||||
|
||||
change manage_relationship(:owner_id, :owner, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:acls, type: :append_and_remove)
|
||||
change WandererApp.Api.Changes.SlugifyName
|
||||
end
|
||||
@@ -113,7 +129,16 @@ defmodule WandererApp.Api.Map do
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
|
||||
|
||||
accept [
|
||||
:name,
|
||||
:slug,
|
||||
:description,
|
||||
:scope,
|
||||
:only_tracked_characters,
|
||||
:owner_id,
|
||||
:sse_enabled
|
||||
]
|
||||
|
||||
argument :owner_id_text_input, :string, allow_nil?: true
|
||||
argument :acls_text_input, :string, allow_nil?: true
|
||||
@@ -128,6 +153,9 @@ defmodule WandererApp.Api.Map do
|
||||
)
|
||||
|
||||
change WandererApp.Api.Changes.SlugifyName
|
||||
|
||||
# Validate subscription when enabling SSE
|
||||
validate &validate_sse_subscription/2
|
||||
end
|
||||
|
||||
update :update_acls do
|
||||
@@ -142,33 +170,46 @@ defmodule WandererApp.Api.Map do
|
||||
|
||||
update :assign_owner do
|
||||
accept [:owner_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_hubs do
|
||||
accept [:hubs]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_options do
|
||||
accept [:options]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :mark_as_deleted do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:deleted, true))
|
||||
end
|
||||
|
||||
update :update_api_key do
|
||||
accept [:public_api_key]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :toggle_webhooks do
|
||||
accept [:webhooks_enabled]
|
||||
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
|
||||
argument :copy_acls, :boolean, default: true
|
||||
argument :copy_user_settings, :boolean, default: true
|
||||
@@ -312,12 +353,19 @@ defmodule WandererApp.Api.Map do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :sse_enabled, :boolean do
|
||||
default(false)
|
||||
allow_nil?(false)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
create_timestamp(:inserted_at)
|
||||
update_timestamp(:updated_at)
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_slug, [:slug]
|
||||
identity :unique_public_api_key, [:public_api_key]
|
||||
end
|
||||
|
||||
relationships do
|
||||
@@ -344,4 +392,49 @@ defmodule WandererApp.Api.Map do
|
||||
public? false
|
||||
end
|
||||
end
|
||||
|
||||
# SSE Subscription Validation
|
||||
#
|
||||
# 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
|
||||
subscriptions_enabled = WandererApp.Env.map_subscriptions_enabled?()
|
||||
|
||||
cond do
|
||||
# Not enabling SSE - no validation needed
|
||||
not sse_enabled ->
|
||||
:ok
|
||||
|
||||
# Map creation (no ID yet) - skip validation
|
||||
is_nil(map_id) ->
|
||||
:ok
|
||||
|
||||
# Community Edition mode - always allow
|
||||
not subscriptions_enabled ->
|
||||
:ok
|
||||
|
||||
# Enterprise mode - check subscription
|
||||
true ->
|
||||
validate_active_subscription(map_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_active_subscription(map_id) do
|
||||
case WandererApp.Map.is_subscription_active?(map_id) do
|
||||
{:ok, true} ->
|
||||
:ok
|
||||
|
||||
{:ok, false} ->
|
||||
{:error, field: :sse_enabled, message: "Active subscription required to enable SSE"}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Error checking subscription status: #{inspect(reason)}")
|
||||
{:error, field: :sse_enabled, message: "Unable to verify subscription status"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,7 +61,11 @@ defmodule WandererApp.Api.MapAccessList do
|
||||
:access_list_id
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :read_by_map do
|
||||
argument(:map_id, :string, allow_nil?: false)
|
||||
|
||||
@@ -27,7 +27,11 @@ defmodule WandererApp.Api.MapChainPassages do
|
||||
:solar_system_target_id
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :new do
|
||||
accept [
|
||||
@@ -40,12 +44,6 @@ defmodule WandererApp.Api.MapChainPassages do
|
||||
]
|
||||
|
||||
primary?(true)
|
||||
|
||||
argument :map_id, :uuid, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
action :by_map_id, {:array, :struct} do
|
||||
|
||||
@@ -81,12 +81,6 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
:character_id,
|
||||
:tracked
|
||||
]
|
||||
|
||||
argument :map_id, :uuid, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
read :by_map_filtered do
|
||||
@@ -145,8 +139,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
|
||||
update :track do
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
@@ -159,8 +152,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
|
||||
update :untrack do
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
@@ -173,8 +165,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
|
||||
update :follow do
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
@@ -187,8 +178,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
|
||||
update :unfollow do
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
|
||||
@@ -4,7 +4,8 @@ defmodule WandererApp.Api.MapConnection do
|
||||
use Ash.Resource,
|
||||
domain: WandererApp.Api,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshJsonApi.Resource]
|
||||
extensions: [AshJsonApi.Resource],
|
||||
primary_read_warning?: false
|
||||
|
||||
postgres do
|
||||
repo(WandererApp.Repo)
|
||||
@@ -73,7 +74,56 @@ defmodule WandererApp.Api.MapConnection do
|
||||
:custom_info
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
create :create do
|
||||
primary? true
|
||||
|
||||
accept [
|
||||
:map_id,
|
||||
:solar_system_source,
|
||||
:solar_system_target,
|
||||
:type,
|
||||
:ship_size_type,
|
||||
:mass_status,
|
||||
:time_status,
|
||||
:wormhole_type,
|
||||
:count_of_passage,
|
||||
:locked,
|
||||
:custom_info
|
||||
]
|
||||
|
||||
# Inject map_id from token
|
||||
change WandererApp.Api.Changes.InjectMapFromActor
|
||||
end
|
||||
|
||||
read :read do
|
||||
primary? true
|
||||
|
||||
# Security: Filter to only connections from actor's map
|
||||
prepare WandererApp.Api.Preparations.FilterConnectionsByActorMap
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
|
||||
accept [
|
||||
:solar_system_source,
|
||||
:solar_system_target,
|
||||
:type,
|
||||
:ship_size_type,
|
||||
:mass_status,
|
||||
:time_status,
|
||||
:wormhole_type,
|
||||
:count_of_passage,
|
||||
:locked,
|
||||
:custom_info
|
||||
]
|
||||
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
primary? true
|
||||
end
|
||||
|
||||
read :read_by_map do
|
||||
argument(:map_id, :string, allow_nil?: false)
|
||||
@@ -110,30 +160,37 @@ defmodule WandererApp.Api.MapConnection do
|
||||
|
||||
update :update_mass_status do
|
||||
accept [:mass_status]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_time_status do
|
||||
accept [:time_status]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_ship_size_type do
|
||||
accept [:ship_size_type]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_locked do
|
||||
accept [:locked]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_custom_info do
|
||||
accept [:custom_info]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_type do
|
||||
accept [:type]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_wormhole_type do
|
||||
accept [:wormhole_type]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@ defmodule WandererApp.Api.MapInvite do
|
||||
:token
|
||||
]
|
||||
|
||||
defaults [:read, :update, :destroy]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :new do
|
||||
accept [
|
||||
@@ -41,10 +45,6 @@ defmodule WandererApp.Api.MapInvite do
|
||||
]
|
||||
|
||||
primary?(true)
|
||||
|
||||
argument :map_id, :uuid, allow_nil?: true
|
||||
|
||||
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
read :by_map do
|
||||
|
||||
@@ -3,7 +3,8 @@ defmodule WandererApp.Api.MapPing do
|
||||
|
||||
use Ash.Resource,
|
||||
domain: WandererApp.Api,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
primary_read_warning?: false
|
||||
|
||||
postgres do
|
||||
repo(WandererApp.Repo)
|
||||
@@ -36,7 +37,18 @@ defmodule WandererApp.Api.MapPing do
|
||||
:message
|
||||
]
|
||||
|
||||
defaults [:read, :update, :destroy]
|
||||
defaults [:destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :read do
|
||||
primary? true
|
||||
|
||||
# Security: Filter to only pings from actor's map
|
||||
prepare WandererApp.Api.Preparations.FilterPingsByActorMap
|
||||
end
|
||||
|
||||
create :new do
|
||||
accept [
|
||||
@@ -48,14 +60,6 @@ defmodule WandererApp.Api.MapPing do
|
||||
]
|
||||
|
||||
primary?(true)
|
||||
|
||||
argument :map_id, :uuid, allow_nil?: false
|
||||
argument :system_id, :uuid, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
read :by_map do
|
||||
|
||||
@@ -65,7 +65,11 @@ defmodule WandererApp.Api.MapSolarSystem do
|
||||
:sun_type_id
|
||||
]
|
||||
|
||||
defaults [:read, :destroy, :update]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
|
||||
@@ -24,7 +24,11 @@ defmodule WandererApp.Api.MapSolarSystemJumps do
|
||||
:to_solar_system_id
|
||||
]
|
||||
|
||||
defaults [:read, :destroy, :update]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
|
||||
@@ -45,7 +45,11 @@ defmodule WandererApp.Api.MapState do
|
||||
:connections_start_time
|
||||
]
|
||||
|
||||
defaults [:read, :update, :destroy]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
|
||||
@@ -62,7 +62,11 @@ defmodule WandererApp.Api.MapSubscription do
|
||||
:auto_renew?
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :all_active do
|
||||
prepare build(sort: [updated_at: :asc], load: [:map])
|
||||
@@ -88,32 +92,39 @@ defmodule WandererApp.Api.MapSubscription do
|
||||
|
||||
update :update_plan do
|
||||
accept [:plan]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_characters_limit do
|
||||
accept [:characters_limit]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_hubs_limit do
|
||||
accept [:hubs_limit]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_active_till do
|
||||
accept [:active_till]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_auto_renew do
|
||||
accept [:auto_renew?]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :cancel do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:status, :cancelled))
|
||||
end
|
||||
|
||||
update :expire do
|
||||
accept([])
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:status, :expired))
|
||||
end
|
||||
|
||||
@@ -24,16 +24,12 @@ defmodule WandererApp.Api.MapSystem do
|
||||
use Ash.Resource,
|
||||
domain: WandererApp.Api,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshJsonApi.Resource]
|
||||
extensions: [AshJsonApi.Resource],
|
||||
primary_read_warning?: false
|
||||
|
||||
postgres do
|
||||
repo(WandererApp.Repo)
|
||||
table("map_system_v1")
|
||||
|
||||
custom_indexes do
|
||||
# Partial index for efficient visible systems query
|
||||
index [:map_id], where: "visible = true", name: "map_system_v1_map_id_visible_index"
|
||||
end
|
||||
end
|
||||
|
||||
json_api do
|
||||
@@ -67,12 +63,10 @@ defmodule WandererApp.Api.MapSystem do
|
||||
|
||||
code_interface do
|
||||
define(:create, action: :create)
|
||||
define(:upsert, action: :upsert)
|
||||
define(:destroy, action: :destroy)
|
||||
|
||||
define(:by_id,
|
||||
get_by: [:id],
|
||||
action: :read
|
||||
)
|
||||
define :by_id, action: :get_by_id, args: [:id], get?: true
|
||||
|
||||
define(:by_solar_system_id,
|
||||
get_by: [:solar_system_id],
|
||||
@@ -102,6 +96,7 @@ defmodule WandererApp.Api.MapSystem do
|
||||
define(:update_status, action: :update_status)
|
||||
define(:update_tag, action: :update_tag)
|
||||
define(:update_temporary_name, action: :update_temporary_name)
|
||||
define(:update_custom_name, action: :update_custom_name)
|
||||
define(:update_labels, action: :update_labels)
|
||||
define(:update_linked_sig_eve_id, action: :update_linked_sig_eve_id)
|
||||
define(:update_position, action: :update_position)
|
||||
@@ -127,11 +122,88 @@ defmodule WandererApp.Api.MapSystem do
|
||||
:linked_sig_eve_id
|
||||
]
|
||||
|
||||
defaults [:create, :update, :destroy]
|
||||
create :create do
|
||||
primary? true
|
||||
|
||||
accept [
|
||||
:map_id,
|
||||
:name,
|
||||
:solar_system_id,
|
||||
:position_x,
|
||||
:position_y,
|
||||
:status,
|
||||
:visible,
|
||||
:locked,
|
||||
:custom_name,
|
||||
:description,
|
||||
:tag,
|
||||
:temporary_name,
|
||||
:labels,
|
||||
:added_at,
|
||||
:linked_sig_eve_id
|
||||
]
|
||||
|
||||
# Inject map_id from token
|
||||
change WandererApp.Api.Changes.InjectMapFromActor
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
|
||||
# Note: name and solar_system_id are not in accept
|
||||
# - solar_system_id should be immutable (identifier)
|
||||
# - name has allow_nil? false which makes it required in JSON:API
|
||||
accept [
|
||||
:position_x,
|
||||
:position_y,
|
||||
:status,
|
||||
:visible,
|
||||
:locked,
|
||||
:custom_name,
|
||||
:description,
|
||||
:tag,
|
||||
:temporary_name,
|
||||
:labels,
|
||||
:linked_sig_eve_id
|
||||
]
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
primary? true
|
||||
end
|
||||
|
||||
create :upsert do
|
||||
primary? false
|
||||
upsert? true
|
||||
upsert_identity :map_solar_system_id
|
||||
|
||||
# Update these fields on conflict
|
||||
upsert_fields [
|
||||
:position_x,
|
||||
:position_y,
|
||||
:visible,
|
||||
:name
|
||||
]
|
||||
|
||||
accept [
|
||||
:map_id,
|
||||
:solar_system_id,
|
||||
:name,
|
||||
:position_x,
|
||||
:position_y,
|
||||
:visible,
|
||||
:locked,
|
||||
:status
|
||||
]
|
||||
end
|
||||
|
||||
read :read do
|
||||
primary?(true)
|
||||
|
||||
# Security: Filter to only systems from actor's map
|
||||
prepare WandererApp.Api.Preparations.FilterSystemsByActorMap
|
||||
|
||||
pagination offset?: true,
|
||||
default_limit: 100,
|
||||
max_page_size: 500,
|
||||
@@ -139,6 +211,11 @@ defmodule WandererApp.Api.MapSystem do
|
||||
required?: false
|
||||
end
|
||||
|
||||
read :get_by_id do
|
||||
argument(:id, :string, allow_nil?: false)
|
||||
filter(expr(id == ^arg(:id)))
|
||||
end
|
||||
|
||||
read :read_all_by_map do
|
||||
argument(:map_id, :string, allow_nil?: false)
|
||||
filter(expr(map_id == ^arg(:map_id)))
|
||||
@@ -160,44 +237,59 @@ defmodule WandererApp.Api.MapSystem do
|
||||
|
||||
update :update_name do
|
||||
accept [:name]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_description do
|
||||
accept [:description]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_locked do
|
||||
accept [:locked]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_status do
|
||||
accept [:status]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_tag do
|
||||
accept [:tag]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_temporary_name do
|
||||
accept [:temporary_name]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_custom_name do
|
||||
accept [:custom_name]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_labels do
|
||||
accept [:labels]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_position do
|
||||
accept [:position_x, :position_y]
|
||||
require_atomic? false
|
||||
|
||||
change(set_attribute(:visible, true))
|
||||
end
|
||||
|
||||
update :update_linked_sig_eve_id do
|
||||
accept [:linked_sig_eve_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_visible do
|
||||
accept [:visible]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -59,12 +59,6 @@ defmodule WandererApp.Api.MapSystemComment do
|
||||
:character_id,
|
||||
:text
|
||||
]
|
||||
|
||||
argument :system_id, :uuid, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
read :by_system_id do
|
||||
|
||||
@@ -111,10 +111,6 @@ defmodule WandererApp.Api.MapSystemSignature do
|
||||
:custom_info,
|
||||
:deleted
|
||||
]
|
||||
|
||||
argument :system_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
update :update do
|
||||
@@ -139,14 +135,17 @@ defmodule WandererApp.Api.MapSystemSignature do
|
||||
|
||||
update :update_linked_system do
|
||||
accept [:linked_system_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_type do
|
||||
accept [:type]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_group do
|
||||
accept [:group]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :by_system_id do
|
||||
|
||||
@@ -122,13 +122,6 @@ defmodule WandererApp.Api.MapSystemStructure do
|
||||
:status,
|
||||
:end_time
|
||||
]
|
||||
|
||||
argument :system_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:system_id, :system,
|
||||
on_lookup: :relate,
|
||||
on_no_match: nil
|
||||
)
|
||||
end
|
||||
|
||||
update :update do
|
||||
|
||||
@@ -29,7 +29,11 @@ defmodule WandererApp.Api.MapTransaction do
|
||||
:amount
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :by_map do
|
||||
argument(:map_id, :string, allow_nil?: false)
|
||||
|
||||
@@ -53,22 +53,30 @@ defmodule WandererApp.Api.MapUserSettings do
|
||||
:settings
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_settings do
|
||||
accept [:settings]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_main_character do
|
||||
accept [:main_character_eve_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_following_character do
|
||||
accept [:following_character_eve_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_hubs do
|
||||
accept [:hubs]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ defmodule WandererApp.Api.MapWebhookSubscription do
|
||||
:consecutive_failures,
|
||||
:secret
|
||||
]
|
||||
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :by_map do
|
||||
|
||||
64
lib/wanderer_app/api/preparations/filter_by_actor_map.ex
Normal file
64
lib/wanderer_app/api/preparations/filter_by_actor_map.ex
Normal file
@@ -0,0 +1,64 @@
|
||||
defmodule WandererApp.Api.Preparations.FilterByActorMap do
|
||||
@moduledoc """
|
||||
Shared filtering logic for actor map context.
|
||||
|
||||
Filters queries to only return resources belonging to the actor's map.
|
||||
Used by preparations for MapSystem, MapConnection, and MapPing resources.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias WandererApp.Api.ActorHelpers
|
||||
|
||||
@doc """
|
||||
Filter a query by the actor's map context.
|
||||
|
||||
If a map is found in the context, filters the query to only return
|
||||
resources where map_id matches. If no map context exists, returns
|
||||
a query that will return no results.
|
||||
|
||||
## Parameters
|
||||
|
||||
* `query` - The Ash query to filter
|
||||
* `context` - The Ash context containing actor/map information
|
||||
* `resource_name` - Name of the resource for telemetry (atom)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> query = Ash.Query.new(WandererApp.Api.MapSystem)
|
||||
iex> context = %{map: %{id: "map-123"}}
|
||||
iex> result = FilterByActorMap.filter_by_map(query, context, :map_system)
|
||||
# Returns query filtered by map_id == "map-123"
|
||||
"""
|
||||
def filter_by_map(query, context, resource_name) do
|
||||
case ActorHelpers.get_map(context) do
|
||||
%{id: map_id} ->
|
||||
emit_telemetry(resource_name, map_id)
|
||||
Ash.Query.filter(query, map_id == ^map_id)
|
||||
|
||||
nil ->
|
||||
emit_telemetry_no_context(resource_name)
|
||||
Ash.Query.filter(query, false)
|
||||
|
||||
_other ->
|
||||
emit_telemetry_no_context(resource_name)
|
||||
Ash.Query.filter(query, false)
|
||||
end
|
||||
end
|
||||
|
||||
defp emit_telemetry(resource_name, map_id) do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :ash, :preparation, :filter_by_map],
|
||||
%{count: 1},
|
||||
%{resource: resource_name, map_id: map_id}
|
||||
)
|
||||
end
|
||||
|
||||
defp emit_telemetry_no_context(resource_name) do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :ash, :preparation, :filter_by_map, :no_context],
|
||||
%{count: 1},
|
||||
%{resource: resource_name}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
defmodule WandererApp.Api.Preparations.FilterConnectionsByActorMap do
|
||||
@moduledoc """
|
||||
Ash preparation that filters connections to only those from the actor's map.
|
||||
|
||||
For token-based auth, this ensures the API only returns connections
|
||||
from the map associated with the token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
|
||||
alias WandererApp.Api.Preparations.FilterByActorMap
|
||||
|
||||
@impl true
|
||||
def prepare(query, _opts, context) do
|
||||
FilterByActorMap.filter_by_map(query, context, :map_connection)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
defmodule WandererApp.Api.Preparations.FilterPingsByActorMap do
|
||||
@moduledoc """
|
||||
Ash preparation that filters pings to only those from the actor's map.
|
||||
|
||||
For token-based auth, this ensures the API only returns pings
|
||||
from the map associated with the token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
|
||||
alias WandererApp.Api.Preparations.FilterByActorMap
|
||||
|
||||
@impl true
|
||||
def prepare(query, _opts, context) do
|
||||
FilterByActorMap.filter_by_map(query, context, :map_ping)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
defmodule WandererApp.Api.Preparations.FilterSystemsByActorMap do
|
||||
@moduledoc """
|
||||
Ash preparation that filters systems to only those from the actor's map.
|
||||
|
||||
For token-based auth, this ensures the API only returns systems
|
||||
from the map associated with the token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
|
||||
alias WandererApp.Api.Preparations.FilterByActorMap
|
||||
|
||||
@impl true
|
||||
def prepare(query, _opts, context) do
|
||||
FilterByActorMap.filter_by_map(query, context, :map_system)
|
||||
end
|
||||
end
|
||||
62
lib/wanderer_app/api/preparations/secure_api_key_lookup.ex
Normal file
62
lib/wanderer_app/api/preparations/secure_api_key_lookup.ex
Normal file
@@ -0,0 +1,62 @@
|
||||
defmodule WandererApp.Api.Preparations.SecureApiKeyLookup do
|
||||
@moduledoc """
|
||||
Preparation that performs secure API key lookup using constant-time comparison.
|
||||
|
||||
This preparation:
|
||||
1. Queries for the map with the given API key using database index
|
||||
2. Performs constant-time comparison to verify the key matches
|
||||
3. Returns the map only if the secure comparison passes
|
||||
|
||||
The constant-time comparison prevents timing attacks where an attacker
|
||||
could deduce information about valid API keys by measuring response times.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
require Ash.Query
|
||||
|
||||
@dummy_key "dummy_key_for_timing_consistency_00000000"
|
||||
|
||||
def prepare(query, _params, _context) do
|
||||
api_key = Ash.Query.get_argument(query, :api_key)
|
||||
|
||||
if is_nil(api_key) or api_key == "" do
|
||||
# Return empty result for invalid input
|
||||
Ash.Query.filter(query, expr(false))
|
||||
else
|
||||
# First, do the database lookup using the index
|
||||
# Then apply constant-time comparison in after_action
|
||||
query
|
||||
|> Ash.Query.filter(expr(public_api_key == ^api_key))
|
||||
|> Ash.Query.after_action(fn _query, results ->
|
||||
verify_results_with_secure_compare(results, api_key)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_results_with_secure_compare(results, provided_key) do
|
||||
case results do
|
||||
[map] ->
|
||||
# Map found - verify with constant-time comparison
|
||||
stored_key = map.public_api_key || @dummy_key
|
||||
|
||||
if Plug.Crypto.secure_compare(stored_key, provided_key) do
|
||||
{:ok, [map]}
|
||||
else
|
||||
# Keys don't match (shouldn't happen if DB returned it, but safety check)
|
||||
{:ok, []}
|
||||
end
|
||||
|
||||
[] ->
|
||||
# No map found - still do a comparison to maintain consistent timing
|
||||
# This prevents timing attacks from distinguishing "not found" from "found but wrong"
|
||||
_result = Plug.Crypto.secure_compare(@dummy_key, provided_key)
|
||||
{:ok, []}
|
||||
|
||||
_multiple ->
|
||||
# Multiple results - shouldn't happen with unique constraint
|
||||
# Do comparison for timing consistency and return error
|
||||
_result = Plug.Crypto.secure_compare(@dummy_key, provided_key)
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -49,7 +49,11 @@ defmodule WandererApp.Api.ShipTypeInfo do
|
||||
:volume
|
||||
]
|
||||
|
||||
defaults [:read, :destroy, :update]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
|
||||
@@ -51,10 +51,15 @@ defmodule WandererApp.Api.User do
|
||||
:hash
|
||||
]
|
||||
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:create, :read, :destroy]
|
||||
|
||||
update :update do
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_last_map do
|
||||
accept([:last_map_id])
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_balance do
|
||||
|
||||
@@ -4,7 +4,8 @@ defmodule WandererApp.Api.UserActivity do
|
||||
use Ash.Resource,
|
||||
domain: WandererApp.Api,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshJsonApi.Resource]
|
||||
extensions: [AshJsonApi.Resource],
|
||||
primary_read_warning?: false
|
||||
|
||||
require Ash.Expr
|
||||
|
||||
@@ -55,7 +56,8 @@ defmodule WandererApp.Api.UserActivity do
|
||||
:entity_type,
|
||||
:event_type,
|
||||
:event_data,
|
||||
:user_id
|
||||
:user_id,
|
||||
:character_id
|
||||
]
|
||||
|
||||
read :read do
|
||||
@@ -70,14 +72,8 @@ defmodule WandererApp.Api.UserActivity do
|
||||
end
|
||||
|
||||
create :new do
|
||||
accept [:entity_id, :entity_type, :event_type, :event_data]
|
||||
accept [:entity_id, :entity_type, :event_type, :event_data, :user_id, :character_id]
|
||||
primary?(true)
|
||||
|
||||
argument :user_id, :uuid, allow_nil?: true
|
||||
argument :character_id, :uuid, allow_nil?: true
|
||||
|
||||
change manage_relationship(:user_id, :user, on_lookup: :relate, on_no_match: nil)
|
||||
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
|
||||
destroy :archive do
|
||||
|
||||
@@ -28,10 +28,6 @@ defmodule WandererApp.Api.UserTransaction do
|
||||
create :new do
|
||||
accept [:journal_ref_id, :user_id, :date, :amount, :corporation_id]
|
||||
primary?(true)
|
||||
|
||||
argument :user_id, :uuid, allow_nil?: false
|
||||
|
||||
change manage_relationship(:user_id, :user, on_lookup: :relate, on_no_match: nil)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -16,15 +16,48 @@ defmodule WandererApp.Application do
|
||||
WandererApp.Vault,
|
||||
WandererApp.Repo,
|
||||
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
|
||||
# Multiple Finch pools for different services to prevent connection pool exhaustion
|
||||
# ESI Character Tracking pool - high capacity for bulk character operations
|
||||
{
|
||||
Finch,
|
||||
name: WandererApp.Finch.ESI.CharacterTracking,
|
||||
pools: %{
|
||||
default: [
|
||||
size: Application.get_env(:wanderer_app, :finch_esi_character_pool_size, 100),
|
||||
count: Application.get_env(:wanderer_app, :finch_esi_character_pool_count, 4)
|
||||
]
|
||||
}
|
||||
},
|
||||
# ESI General pool - standard capacity for general ESI operations
|
||||
{
|
||||
Finch,
|
||||
name: WandererApp.Finch.ESI.General,
|
||||
pools: %{
|
||||
default: [
|
||||
size: Application.get_env(:wanderer_app, :finch_esi_general_pool_size, 50),
|
||||
count: Application.get_env(:wanderer_app, :finch_esi_general_pool_count, 4)
|
||||
]
|
||||
}
|
||||
},
|
||||
# Webhooks pool - isolated from ESI rate limits
|
||||
{
|
||||
Finch,
|
||||
name: WandererApp.Finch.Webhooks,
|
||||
pools: %{
|
||||
default: [
|
||||
size: Application.get_env(:wanderer_app, :finch_webhooks_pool_size, 25),
|
||||
count: Application.get_env(:wanderer_app, :finch_webhooks_pool_count, 2)
|
||||
]
|
||||
}
|
||||
},
|
||||
# Default pool - everything else (email, license manager, etc.)
|
||||
{
|
||||
Finch,
|
||||
name: WandererApp.Finch,
|
||||
pools: %{
|
||||
default: [
|
||||
# number of connections per pool
|
||||
size: 50,
|
||||
# number of pools (so total 50 connections)
|
||||
count: 4
|
||||
size: Application.get_env(:wanderer_app, :finch_default_pool_size, 25),
|
||||
count: Application.get_env(:wanderer_app, :finch_default_pool_count, 2)
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -120,13 +153,16 @@ defmodule WandererApp.Application do
|
||||
:ok
|
||||
end
|
||||
|
||||
defp maybe_start_corp_wallet_tracker(true),
|
||||
do: [
|
||||
WandererApp.StartCorpWalletTrackerTask
|
||||
]
|
||||
defp maybe_start_corp_wallet_tracker(true) do
|
||||
# Don't start corp wallet tracker in test environment
|
||||
if Application.get_env(:wanderer_app, :environment) == :test do
|
||||
[]
|
||||
else
|
||||
[WandererApp.StartCorpWalletTrackerTask]
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_start_corp_wallet_tracker(_),
|
||||
do: []
|
||||
defp maybe_start_corp_wallet_tracker(_), do: []
|
||||
|
||||
defp maybe_start_kills_services do
|
||||
# Don't start kills services in test environment
|
||||
|
||||
@@ -73,6 +73,54 @@ defmodule WandererApp.Cache do
|
||||
|
||||
def filter_by_attr_in(type, attr, includes), do: type |> get() |> filter_in(attr, includes)
|
||||
|
||||
@doc """
|
||||
Batch lookup multiple keys from cache.
|
||||
Returns a map of key => value pairs, with `default` used for missing keys.
|
||||
"""
|
||||
def lookup_all(keys, default \\ nil) when is_list(keys) do
|
||||
# Get all values from cache
|
||||
values = get_all(keys)
|
||||
|
||||
# Build result map with defaults for missing keys
|
||||
result =
|
||||
keys
|
||||
|> Enum.map(fn key ->
|
||||
value = Map.get(values, key, default)
|
||||
{key, value}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
{:ok, result}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Batch insert multiple key-value pairs into cache.
|
||||
Accepts a map of key => value pairs or a list of {key, value} tuples.
|
||||
Skips nil values (deletes the key instead).
|
||||
"""
|
||||
def insert_all(entries, opts \\ [])
|
||||
|
||||
def insert_all(entries, opts) when is_map(entries) do
|
||||
# Filter out nil values and delete those keys
|
||||
{to_delete, to_insert} =
|
||||
entries
|
||||
|> Enum.split_with(fn {_key, value} -> is_nil(value) end)
|
||||
|
||||
# Delete keys with nil values
|
||||
Enum.each(to_delete, fn {key, _} -> delete(key) end)
|
||||
|
||||
# Insert non-nil values
|
||||
unless Enum.empty?(to_insert) do
|
||||
put_all(to_insert, opts)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def insert_all(entries, opts) when is_list(entries) do
|
||||
insert_all(Map.new(entries), opts)
|
||||
end
|
||||
|
||||
defp find(list, %{} = attrs, match: match) do
|
||||
list
|
||||
|> Enum.find(fn item ->
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule WandererApp.CachedInfo do
|
||||
require Logger
|
||||
|
||||
alias WandererAppWeb.Helpers.APIUtils
|
||||
|
||||
def run(_arg) do
|
||||
:ok = cache_trig_systems()
|
||||
end
|
||||
@@ -29,14 +31,73 @@ defmodule WandererApp.CachedInfo do
|
||||
)
|
||||
end)
|
||||
|
||||
Cachex.get(:ship_types_cache, type_id)
|
||||
get_ship_type_from_cache_or_api(type_id)
|
||||
|
||||
{:ok, ship_type} ->
|
||||
{:ok, ship_type}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_ship_type_from_cache_or_api(type_id) do
|
||||
case Cachex.get(:ship_types_cache, type_id) do
|
||||
{:ok, ship_type} when not is_nil(ship_type) ->
|
||||
{:ok, ship_type}
|
||||
|
||||
{:ok, nil} ->
|
||||
case WandererApp.Esi.get_type_info(type_id) do
|
||||
{:ok, info} when not is_nil(info) ->
|
||||
ship_type = parse_type(type_id, info)
|
||||
{:ok, group_info} = get_group_info(ship_type.group_id)
|
||||
|
||||
{:ok, ship_type_info} =
|
||||
WandererApp.Api.ShipTypeInfo |> Ash.create(ship_type |> Map.merge(group_info))
|
||||
|
||||
{:ok,
|
||||
ship_type_info
|
||||
|> Map.take([
|
||||
:type_id,
|
||||
:group_id,
|
||||
:group_name,
|
||||
:name,
|
||||
:description,
|
||||
:mass,
|
||||
:capacity,
|
||||
:volume
|
||||
])}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to get ship_type #{type_id} from ESI: #{inspect(reason)}")
|
||||
{:ok, nil}
|
||||
|
||||
error ->
|
||||
Logger.error("Failed to get ship_type #{type_id} from ESI: #{inspect(error)}")
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_group_info(nil), do: {:ok, nil}
|
||||
|
||||
def get_group_info(group_id) do
|
||||
case WandererApp.Esi.get_group_info(group_id) do
|
||||
{:ok, info} when not is_nil(info) ->
|
||||
{:ok, parse_group(group_id, info)}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to get group_info #{group_id} from ESI: #{inspect(reason)}")
|
||||
{:ok, %{group_name: ""}}
|
||||
|
||||
error ->
|
||||
Logger.error("Failed to get group_info #{group_id} from ESI: #{inspect(error)}")
|
||||
{:ok, %{group_name: ""}}
|
||||
end
|
||||
end
|
||||
|
||||
def get_system_static_info(nil), do: {:ok, nil}
|
||||
|
||||
def get_system_static_info(solar_system_id) do
|
||||
{:ok, solar_system_id} = APIUtils.parse_int(solar_system_id)
|
||||
|
||||
case Cachex.get(:system_static_info_cache, solar_system_id) do
|
||||
{:ok, nil} ->
|
||||
case WandererApp.Api.MapSolarSystem.read() do
|
||||
@@ -149,6 +210,25 @@ defmodule WandererApp.CachedInfo do
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_group(group_id, group) do
|
||||
%{
|
||||
group_id: group_id,
|
||||
group_name: Map.get(group, "name")
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_type(type_id, type) do
|
||||
%{
|
||||
type_id: type_id,
|
||||
name: Map.get(type, "name"),
|
||||
description: Map.get(type, "description"),
|
||||
group_id: Map.get(type, "group_id"),
|
||||
mass: "#{Map.get(type, "mass")}",
|
||||
capacity: "#{Map.get(type, "capacity")}",
|
||||
volume: "#{Map.get(type, "volume")}"
|
||||
}
|
||||
end
|
||||
|
||||
defp build_jump_index() do
|
||||
case get_solar_system_jumps() do
|
||||
{:ok, jumps} ->
|
||||
|
||||
@@ -4,6 +4,8 @@ defmodule WandererApp.Character do
|
||||
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Cache
|
||||
|
||||
@read_character_wallet_scope "esi-wallet.read_character_wallet.v1"
|
||||
@read_corp_wallet_scope "esi-wallet.read_corporation_wallets.v1"
|
||||
|
||||
@@ -16,6 +18,9 @@ defmodule WandererApp.Character do
|
||||
ship_item_id: nil
|
||||
}
|
||||
|
||||
@present_on_map_ttl :timer.seconds(10)
|
||||
@not_present_on_map_ttl :timer.minutes(2)
|
||||
|
||||
def get_by_eve_id(character_eve_id) when is_binary(character_eve_id) do
|
||||
WandererApp.Api.Character.by_eve_id(character_eve_id)
|
||||
end
|
||||
@@ -41,7 +46,7 @@ defmodule WandererApp.Character do
|
||||
|
||||
def get_character!(character_id) do
|
||||
case get_character(character_id) do
|
||||
{:ok, character} ->
|
||||
{:ok, character} when not is_nil(character) ->
|
||||
character
|
||||
|
||||
_ ->
|
||||
@@ -50,16 +55,10 @@ defmodule WandererApp.Character do
|
||||
end
|
||||
end
|
||||
|
||||
def get_map_character(map_id, character_id, opts \\ []) do
|
||||
def get_map_character(map_id, character_id) do
|
||||
case get_character(character_id) do
|
||||
{:ok, character} ->
|
||||
# If we are forcing the character to not be present, we merge the character state with map settings
|
||||
character_is_present =
|
||||
if opts |> Keyword.get(:not_present, false) do
|
||||
false
|
||||
else
|
||||
WandererApp.Character.TrackerManager.Impl.character_is_present(map_id, character_id)
|
||||
end
|
||||
{:ok, character} when not is_nil(character) ->
|
||||
character_is_present = character_is_present?(map_id, character_id)
|
||||
|
||||
{:ok,
|
||||
character
|
||||
@@ -187,6 +186,10 @@ defmodule WandererApp.Character do
|
||||
{:ok, result} ->
|
||||
{:ok, result |> prepare_search_results()}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("#{__MODULE__} failed search: #{inspect(error)}")
|
||||
{:ok, []}
|
||||
|
||||
error ->
|
||||
Logger.warning("#{__MODULE__} failed search: #{inspect(error)}")
|
||||
{:ok, []}
|
||||
@@ -263,22 +266,26 @@ defmodule WandererApp.Character do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_merge_map_character_settings(%{id: character_id} = character, _map_id, true) do
|
||||
{:ok, tracking_paused} =
|
||||
WandererApp.Cache.lookup("character:#{character_id}:tracking_paused", false)
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "character-present-#{map_id}-#{character_id}",
|
||||
opts: [ttl: @present_on_map_ttl]
|
||||
)
|
||||
defp character_is_present?(map_id, character_id),
|
||||
do: WandererApp.Character.TrackerManager.Impl.character_is_present(map_id, character_id)
|
||||
|
||||
character
|
||||
|> Map.merge(%{tracking_paused: tracking_paused})
|
||||
end
|
||||
defp maybe_merge_map_character_settings(character, _map_id, true), do: character
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "not-present-map-character-#{map_id}-#{character_id}",
|
||||
opts: [ttl: @not_present_on_map_ttl]
|
||||
)
|
||||
defp maybe_merge_map_character_settings(
|
||||
%{id: character_id} = character,
|
||||
map_id,
|
||||
_character_is_present
|
||||
false
|
||||
) do
|
||||
{:ok, tracking_paused} =
|
||||
WandererApp.Cache.lookup("character:#{character_id}:tracking_paused", false)
|
||||
|
||||
WandererApp.MapCharacterSettingsRepo.get(map_id, character_id)
|
||||
|> case do
|
||||
{:ok, settings} when not is_nil(settings) ->
|
||||
@@ -296,7 +303,7 @@ defmodule WandererApp.Character do
|
||||
character
|
||||
|> Map.merge(@default_character_tracking_data)
|
||||
end
|
||||
|> Map.merge(%{online: false, tracking_paused: tracking_paused})
|
||||
|> Map.merge(%{online: false})
|
||||
end
|
||||
|
||||
defp prepare_search_results(result) do
|
||||
@@ -324,7 +331,7 @@ defmodule WandererApp.Character do
|
||||
do:
|
||||
{:ok,
|
||||
Enum.map(eve_ids, fn eve_id ->
|
||||
Task.async(fn -> apply(WandererApp.Esi.ApiClient, method, [eve_id]) end)
|
||||
Task.async(fn -> apply(WandererApp.Esi, method, [eve_id]) end)
|
||||
end)
|
||||
# 145000 == Timeout in milliseconds
|
||||
|> Enum.map(fn task -> Task.await(task, 145_000) end)
|
||||
|
||||
@@ -43,13 +43,14 @@ defmodule WandererApp.Character.Activity do
|
||||
## Parameters
|
||||
- `map_id`: ID of the map
|
||||
- `current_user`: Current user struct (used only to get user settings)
|
||||
- `days`: Optional number of days to filter activity (nil for all time)
|
||||
|
||||
## Returns
|
||||
- List of processed activity data
|
||||
"""
|
||||
def process_character_activity(map_id, current_user) do
|
||||
def process_character_activity(map_id, current_user, days \\ nil) do
|
||||
with {:ok, map_user_settings} <- get_map_user_settings(map_id, current_user.id),
|
||||
{:ok, raw_activity} <- WandererApp.Map.get_character_activity(map_id),
|
||||
{:ok, raw_activity} <- WandererApp.Map.get_character_activity(map_id, days),
|
||||
{:ok, user_characters} <-
|
||||
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id}) do
|
||||
process_activity_data(raw_activity, map_user_settings, user_characters)
|
||||
|
||||
@@ -14,8 +14,8 @@ defmodule WandererApp.Character.Tracker do
|
||||
active_maps: [],
|
||||
is_online: false,
|
||||
track_online: true,
|
||||
track_location: true,
|
||||
track_ship: true,
|
||||
track_location: false,
|
||||
track_ship: false,
|
||||
track_wallet: false,
|
||||
status: "new"
|
||||
]
|
||||
@@ -36,14 +36,11 @@ defmodule WandererApp.Character.Tracker do
|
||||
status: binary()
|
||||
}
|
||||
|
||||
@pause_tracking_timeout :timer.minutes(60 * 10)
|
||||
@offline_timeout :timer.minutes(5)
|
||||
@online_error_timeout :timer.minutes(10)
|
||||
@ship_error_timeout :timer.minutes(10)
|
||||
@location_error_timeout :timer.minutes(10)
|
||||
@location_error_timeout :timer.seconds(30)
|
||||
@location_error_threshold 3
|
||||
@online_forbidden_ttl :timer.seconds(7)
|
||||
@offline_check_delay_ttl :timer.seconds(15)
|
||||
@online_limit_ttl :timer.seconds(7)
|
||||
@forbidden_ttl :timer.seconds(10)
|
||||
@limit_ttl :timer.seconds(5)
|
||||
@location_limit_ttl :timer.seconds(1)
|
||||
@@ -93,81 +90,16 @@ defmodule WandererApp.Character.Tracker do
|
||||
end
|
||||
end
|
||||
|
||||
def check_online_errors(character_id),
|
||||
do: check_tracking_errors(character_id, "online", @online_error_timeout)
|
||||
|
||||
def check_ship_errors(character_id),
|
||||
do: check_tracking_errors(character_id, "ship", @ship_error_timeout)
|
||||
|
||||
def check_location_errors(character_id),
|
||||
do: check_tracking_errors(character_id, "location", @location_error_timeout)
|
||||
|
||||
defp check_tracking_errors(character_id, type, timeout) do
|
||||
WandererApp.Cache.lookup!("character:#{character_id}:#{type}_error_time")
|
||||
|> case do
|
||||
nil ->
|
||||
:skip
|
||||
|
||||
error_time ->
|
||||
duration = DateTime.diff(DateTime.utc_now(), error_time, :millisecond)
|
||||
|
||||
if duration >= timeout do
|
||||
pause_tracking(character_id)
|
||||
WandererApp.Cache.delete("character:#{character_id}:#{type}_error_time")
|
||||
|
||||
:ok
|
||||
else
|
||||
:skip
|
||||
end
|
||||
end
|
||||
defp increment_location_error_count(character_id) do
|
||||
cache_key = "character:#{character_id}:location_error_count"
|
||||
current_count = WandererApp.Cache.lookup!(cache_key) || 0
|
||||
new_count = current_count + 1
|
||||
WandererApp.Cache.put(cache_key, new_count)
|
||||
new_count
|
||||
end
|
||||
|
||||
defp pause_tracking(character_id) do
|
||||
if WandererApp.Character.can_pause_tracking?(character_id) &&
|
||||
not WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused") do
|
||||
# Log character tracking statistics before pausing
|
||||
Logger.debug(fn ->
|
||||
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
|
||||
|
||||
"CHARACTER_TRACKING_PAUSED: Character tracking paused due to sustained errors: #{inspect(character_id: character_id,
|
||||
active_maps: length(character_state.active_maps),
|
||||
is_online: character_state.is_online,
|
||||
tracking_duration_minutes: get_tracking_duration_minutes(character_id))}"
|
||||
end)
|
||||
|
||||
WandererApp.Cache.delete("character:#{character_id}:online_forbidden")
|
||||
WandererApp.Cache.delete("character:#{character_id}:online_error_time")
|
||||
WandererApp.Cache.delete("character:#{character_id}:ship_error_time")
|
||||
WandererApp.Cache.delete("character:#{character_id}:location_error_time")
|
||||
WandererApp.Character.update_character(character_id, %{online: false})
|
||||
|
||||
WandererApp.Character.update_character_state(character_id, %{
|
||||
is_online: false
|
||||
})
|
||||
|
||||
# Original log kept for backward compatibility
|
||||
Logger.warning("[CharacterTracker] paused for #{character_id}")
|
||||
|
||||
WandererApp.Cache.put(
|
||||
"character:#{character_id}:tracking_paused",
|
||||
true,
|
||||
ttl: @pause_tracking_timeout
|
||||
)
|
||||
|
||||
{:ok, %{solar_system_id: solar_system_id}} =
|
||||
WandererApp.Character.get_character(character_id)
|
||||
|
||||
{:ok, %{active_maps: active_maps}} =
|
||||
WandererApp.Character.get_character_state(character_id)
|
||||
|
||||
active_maps
|
||||
|> Enum.each(fn map_id ->
|
||||
WandererApp.Cache.put(
|
||||
"map:#{map_id}:character:#{character_id}:start_solar_system_id",
|
||||
solar_system_id
|
||||
)
|
||||
end)
|
||||
end
|
||||
defp reset_location_error_count(character_id) do
|
||||
WandererApp.Cache.delete("character:#{character_id}:location_error_count")
|
||||
end
|
||||
|
||||
def update_settings(character_id, track_settings) do
|
||||
@@ -194,8 +126,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
case WandererApp.Character.get_character(character_id) do
|
||||
{:ok, %{eve_id: eve_id, access_token: access_token, tracking_pool: tracking_pool}}
|
||||
when not is_nil(access_token) ->
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden")
|
||||
|> case do
|
||||
true ->
|
||||
{:error, :skipped}
|
||||
@@ -224,9 +155,10 @@ defmodule WandererApp.Character.Tracker do
|
||||
)
|
||||
end
|
||||
|
||||
if online.online == true && online.online != is_online do
|
||||
if online.online == true && not is_online do
|
||||
WandererApp.Cache.delete("character:#{character_id}:ship_error_time")
|
||||
WandererApp.Cache.delete("character:#{character_id}:location_error_time")
|
||||
WandererApp.Cache.delete("character:#{character_id}:location_error_count")
|
||||
WandererApp.Cache.delete("character:#{character_id}:info_forbidden")
|
||||
WandererApp.Cache.delete("character:#{character_id}:ship_forbidden")
|
||||
WandererApp.Cache.delete("character:#{character_id}:location_forbidden")
|
||||
@@ -294,12 +226,6 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:error, :error_limited, headers} ->
|
||||
reset_timeout = get_reset_timeout(headers)
|
||||
|
||||
reset_seconds =
|
||||
Map.get(headers, "x-esi-error-limit-reset", ["unknown"]) |> List.first()
|
||||
|
||||
remaining =
|
||||
Map.get(headers, "x-esi-error-limit-remain", ["unknown"]) |> List.first()
|
||||
|
||||
WandererApp.Cache.put(
|
||||
"character:#{character_id}:online_forbidden",
|
||||
true,
|
||||
@@ -357,8 +283,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
defp get_reset_timeout(_headers, default_timeout), do: default_timeout
|
||||
|
||||
def update_info(character_id) do
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:info_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:info_forbidden")
|
||||
|> case do
|
||||
true ->
|
||||
{:error, :skipped}
|
||||
@@ -442,8 +367,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:ok, %{eve_id: eve_id, access_token: access_token, tracking_pool: tracking_pool}}
|
||||
when not is_nil(access_token) ->
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:ship_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:ship_forbidden"))
|
||||
|> case do
|
||||
true ->
|
||||
{:error, :skipped}
|
||||
@@ -552,7 +476,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
case WandererApp.Character.get_character(character_id) do
|
||||
{:ok, %{eve_id: eve_id, access_token: access_token, tracking_pool: tracking_pool}}
|
||||
when not is_nil(access_token) ->
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused")
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:location_forbidden")
|
||||
|> case do
|
||||
true ->
|
||||
{:error, :skipped}
|
||||
@@ -565,19 +489,33 @@ defmodule WandererApp.Character.Tracker do
|
||||
character_id: character_id
|
||||
) do
|
||||
{:ok, location} when is_map(location) and not is_struct(location) ->
|
||||
reset_location_error_count(character_id)
|
||||
WandererApp.Cache.delete("character:#{character_id}:location_error_time")
|
||||
|
||||
character_state
|
||||
|> maybe_update_location(location)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, error} when error in [:forbidden, :not_found, :timeout] ->
|
||||
error_count = increment_location_error_count(character_id)
|
||||
|
||||
Logger.warning("ESI_ERROR: Character location tracking failed",
|
||||
character_id: character_id,
|
||||
tracking_pool: tracking_pool,
|
||||
error_type: error,
|
||||
error_count: error_count,
|
||||
endpoint: "character_location"
|
||||
)
|
||||
|
||||
if error_count >= @location_error_threshold do
|
||||
WandererApp.Cache.put(
|
||||
"character:#{character_id}:location_forbidden",
|
||||
true,
|
||||
ttl: @location_error_timeout
|
||||
)
|
||||
end
|
||||
|
||||
if is_nil(
|
||||
WandererApp.Cache.lookup!("character:#{character_id}:location_error_time")
|
||||
) do
|
||||
@@ -601,13 +539,24 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:error, :error_limited}
|
||||
|
||||
{:error, error} ->
|
||||
error_count = increment_location_error_count(character_id)
|
||||
|
||||
Logger.error("ESI_ERROR: Character location tracking failed: #{inspect(error)}",
|
||||
character_id: character_id,
|
||||
tracking_pool: tracking_pool,
|
||||
error_type: error,
|
||||
error_count: error_count,
|
||||
endpoint: "character_location"
|
||||
)
|
||||
|
||||
if error_count >= @location_error_threshold do
|
||||
WandererApp.Cache.put(
|
||||
"character:#{character_id}:location_forbidden",
|
||||
true,
|
||||
ttl: @location_error_timeout
|
||||
)
|
||||
end
|
||||
|
||||
if is_nil(
|
||||
WandererApp.Cache.lookup!("character:#{character_id}:location_error_time")
|
||||
) do
|
||||
@@ -620,13 +569,24 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:error, :skipped}
|
||||
|
||||
_ ->
|
||||
error_count = increment_location_error_count(character_id)
|
||||
|
||||
Logger.error("ESI_ERROR: Character location tracking failed - wrong response",
|
||||
character_id: character_id,
|
||||
tracking_pool: tracking_pool,
|
||||
error_type: "wrong_response",
|
||||
error_count: error_count,
|
||||
endpoint: "character_location"
|
||||
)
|
||||
|
||||
if error_count >= @location_error_threshold do
|
||||
WandererApp.Cache.put(
|
||||
"character:#{character_id}:location_forbidden",
|
||||
true,
|
||||
ttl: @location_error_timeout
|
||||
)
|
||||
end
|
||||
|
||||
if is_nil(
|
||||
WandererApp.Cache.lookup!("character:#{character_id}:location_error_time")
|
||||
) do
|
||||
@@ -662,8 +622,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
|> case do
|
||||
true ->
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:wallet_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:wallet_forbidden"))
|
||||
|> case do
|
||||
true ->
|
||||
{:error, :skipped}
|
||||
@@ -750,6 +709,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
end
|
||||
end
|
||||
|
||||
# when old_alliance_id != alliance_id and is_nil(alliance_id)
|
||||
defp maybe_update_alliance(
|
||||
%{character_id: character_id, alliance_id: old_alliance_id} = state,
|
||||
alliance_id
|
||||
@@ -775,6 +735,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
)
|
||||
|
||||
state
|
||||
|> Map.merge(%{alliance_id: nil})
|
||||
end
|
||||
|
||||
defp maybe_update_alliance(
|
||||
@@ -782,8 +743,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
alliance_id
|
||||
)
|
||||
when old_alliance_id != alliance_id do
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden")
|
||||
|> case do
|
||||
true ->
|
||||
state
|
||||
@@ -813,6 +773,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
)
|
||||
|
||||
state
|
||||
|> Map.merge(%{alliance_id: alliance_id})
|
||||
|
||||
_error ->
|
||||
Logger.error("Failed to get alliance info for #{alliance_id}")
|
||||
@@ -829,8 +790,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
)
|
||||
when old_corporation_id != corporation_id do
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:corporation_info_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:corporation_info_forbidden"))
|
||||
|> case do
|
||||
true ->
|
||||
state
|
||||
@@ -1006,9 +966,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
),
|
||||
do: %{
|
||||
state
|
||||
| track_online: true,
|
||||
track_location: true,
|
||||
track_ship: true
|
||||
| track_online: true
|
||||
}
|
||||
|
||||
defp maybe_start_online_tracking(
|
||||
@@ -1052,11 +1010,6 @@ defmodule WandererApp.Character.Tracker do
|
||||
DateTime.utc_now()
|
||||
)
|
||||
|
||||
WandererApp.Cache.put(
|
||||
"map:#{map_id}:character:#{character_id}:start_solar_system_id",
|
||||
track_settings |> Map.get(:solar_system_id)
|
||||
)
|
||||
|
||||
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
|
||||
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:station_id")
|
||||
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:structure_id")
|
||||
@@ -1107,7 +1060,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
)
|
||||
end
|
||||
|
||||
state
|
||||
%{state | track_location: false, track_ship: false}
|
||||
end
|
||||
|
||||
defp maybe_stop_tracking(
|
||||
@@ -1137,19 +1090,6 @@ defmodule WandererApp.Character.Tracker do
|
||||
|
||||
defp get_online(_), do: %{online: false}
|
||||
|
||||
defp get_tracking_duration_minutes(character_id) do
|
||||
case WandererApp.Cache.lookup!("character:#{character_id}:map:*:tracking_start_time") do
|
||||
nil ->
|
||||
0
|
||||
|
||||
start_time when is_struct(start_time, DateTime) ->
|
||||
DateTime.diff(DateTime.utc_now(), start_time, :minute)
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
# Telemetry handler for database pool monitoring
|
||||
def handle_pool_query(_event_name, measurements, metadata, _config) do
|
||||
queue_time = measurements[:queue_time]
|
||||
|
||||
@@ -14,8 +14,8 @@ defmodule WandererApp.Character.TrackerManager do
|
||||
GenServer.start_link(__MODULE__, args, name: __MODULE__)
|
||||
end
|
||||
|
||||
def start_tracking(character_id, opts \\ []),
|
||||
do: GenServer.cast(__MODULE__, {&Impl.start_tracking/3, [character_id, opts]})
|
||||
def start_tracking(character_id),
|
||||
do: GenServer.cast(__MODULE__, {&Impl.start_tracking/2, [character_id]})
|
||||
|
||||
def stop_tracking(character_id),
|
||||
do: GenServer.cast(__MODULE__, {&Impl.stop_tracking/2, [character_id]})
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Implementation of the character tracker manager.
|
||||
|
||||
This module manages the lifecycle of character trackers and handles:
|
||||
- Starting/stopping character tracking
|
||||
- Garbage collection of inactive trackers (5-minute timeout)
|
||||
- Processing the untrack queue (5-minute interval)
|
||||
|
||||
## Logging
|
||||
|
||||
This module emits detailed logs for debugging character tracking issues:
|
||||
- WARNING: Unexpected states or potential issues
|
||||
- DEBUG: Start/stop tracking events, garbage collection, queue processing
|
||||
"""
|
||||
require Logger
|
||||
|
||||
defstruct [
|
||||
@@ -27,6 +40,11 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)
|
||||
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
|
||||
|
||||
Logger.debug("[TrackerManager] Initialized with intervals: " <>
|
||||
"garbage_collection=#{div(@garbage_collection_interval, 60_000)}min, " <>
|
||||
"untrack=#{div(@untrack_characters_interval, 60_000)}min, " <>
|
||||
"inactive_timeout=#{div(@inactive_character_timeout, 60_000)}min")
|
||||
|
||||
%{
|
||||
characters: [],
|
||||
opts: args
|
||||
@@ -38,22 +56,28 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
{:ok, tracked_characters} = WandererApp.Cache.lookup("tracked_characters", [])
|
||||
WandererApp.Cache.insert("tracked_characters", [])
|
||||
|
||||
if length(tracked_characters) > 0 do
|
||||
Logger.debug("[TrackerManager] Restoring #{length(tracked_characters)} tracked characters from cache")
|
||||
end
|
||||
|
||||
tracked_characters
|
||||
|> Enum.each(fn character_id ->
|
||||
start_tracking(state, character_id, %{})
|
||||
start_tracking(state, character_id)
|
||||
end)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def start_tracking(state, character_id, opts) do
|
||||
def start_tracking(state, character_id) do
|
||||
if not WandererApp.Cache.has_key?("#{character_id}:track_requested") do
|
||||
WandererApp.Cache.insert(
|
||||
"#{character_id}:track_requested",
|
||||
true
|
||||
)
|
||||
|
||||
Logger.debug(fn -> "Add character to track_characters_queue: #{inspect(character_id)}" end)
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Queuing character #{character_id} for tracking start"
|
||||
end)
|
||||
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"track_characters_queue",
|
||||
@@ -71,13 +95,33 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
|
||||
true <- Enum.member?(characters, character_id),
|
||||
false <- WandererApp.Cache.has_key?("#{character_id}:track_requested") do
|
||||
Logger.debug(fn -> "Shutting down character tracker: #{inspect(character_id)}" end)
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Stopping tracker for character #{character_id} - " <>
|
||||
"reason: no active maps (garbage collected after #{div(@inactive_character_timeout, 60_000)} minutes)"
|
||||
end)
|
||||
|
||||
WandererApp.Cache.delete("character:#{character_id}:last_active_time")
|
||||
WandererApp.Character.delete_character_state(character_id)
|
||||
WandererApp.Character.TrackerPoolDynamicSupervisor.stop_tracking(character_id)
|
||||
|
||||
:telemetry.execute([:wanderer_app, :character, :tracker, :stopped], %{count: 1})
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :character, :tracker, :stopped],
|
||||
%{count: 1, system_time: System.system_time()},
|
||||
%{character_id: character_id, reason: :garbage_collection}
|
||||
)
|
||||
else
|
||||
{:ok, characters} when is_list(characters) ->
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Character #{character_id} not in tracked list, skipping stop"
|
||||
end)
|
||||
|
||||
false ->
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Character #{character_id} has pending track request, skipping stop"
|
||||
end)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
WandererApp.Cache.insert_or_update(
|
||||
@@ -101,6 +145,10 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
} = track_settings
|
||||
) do
|
||||
if track do
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Enabling tracking for character #{character_id} on map #{map_id}"
|
||||
end)
|
||||
|
||||
remove_from_untrack_queue(map_id, character_id)
|
||||
|
||||
{:ok, character_state} =
|
||||
@@ -108,6 +156,11 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
|
||||
WandererApp.Character.update_character_state(character_id, character_state)
|
||||
else
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Queuing character #{character_id} for untracking from map #{map_id} - " <>
|
||||
"will be processed within #{div(@untrack_characters_interval, 60_000)} minutes"
|
||||
end)
|
||||
|
||||
add_to_untrack_queue(map_id, character_id)
|
||||
end
|
||||
|
||||
@@ -130,8 +183,19 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
"character_untrack_queue",
|
||||
[],
|
||||
fn untrack_queue ->
|
||||
untrack_queue
|
||||
|> Enum.reject(fn {m_id, c_id} -> m_id == map_id and c_id == character_id end)
|
||||
original_length = length(untrack_queue)
|
||||
filtered =
|
||||
untrack_queue
|
||||
|> Enum.reject(fn {m_id, c_id} -> m_id == map_id and c_id == character_id end)
|
||||
|
||||
if length(filtered) < original_length do
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Removed character #{character_id} from untrack queue for map #{map_id} - " <>
|
||||
"character re-enabled tracking"
|
||||
end)
|
||||
end
|
||||
|
||||
filtered
|
||||
end
|
||||
)
|
||||
end
|
||||
@@ -170,6 +234,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
Process.send_after(self(), :check_start_queue, @check_start_queue_interval)
|
||||
{:ok, track_characters_queue} = WandererApp.Cache.lookup("track_characters_queue", [])
|
||||
|
||||
if length(track_characters_queue) > 0 do
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Processing start queue: #{length(track_characters_queue)} characters"
|
||||
end)
|
||||
end
|
||||
|
||||
track_characters_queue
|
||||
|> Enum.each(fn character_id ->
|
||||
track_character(character_id, %{})
|
||||
@@ -186,35 +256,66 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
|
||||
{:ok, characters} = WandererApp.Cache.lookup("tracked_characters", [])
|
||||
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
case WandererApp.Cache.lookup("character:#{character_id}:last_active_time") do
|
||||
{:ok, nil} ->
|
||||
:skip
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Running garbage collection on #{length(characters)} tracked characters"
|
||||
end)
|
||||
|
||||
{:ok, last_active_time} ->
|
||||
duration = DateTime.diff(DateTime.utc_now(), last_active_time, :second)
|
||||
|
||||
if duration * 1000 > @inactive_character_timeout do
|
||||
{:stop, character_id}
|
||||
else
|
||||
inactive_characters =
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
case WandererApp.Cache.lookup("character:#{character_id}:last_active_time") do
|
||||
{:ok, nil} ->
|
||||
# Character is still active (no last_active_time set)
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(60)
|
||||
)
|
||||
|> Enum.each(fn result ->
|
||||
case result do
|
||||
{:ok, {:stop, character_id}} ->
|
||||
Process.send_after(self(), {:stop_track, character_id}, 100)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
{:ok, last_active_time} ->
|
||||
duration_seconds = DateTime.diff(DateTime.utc_now(), last_active_time, :second)
|
||||
duration_ms = duration_seconds * 1000
|
||||
|
||||
if duration_ms > @inactive_character_timeout do
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Character #{character_id} marked for garbage collection - " <>
|
||||
"inactive for #{div(duration_seconds, 60)} minutes " <>
|
||||
"(threshold: #{div(@inactive_character_timeout, 60_000)} minutes)"
|
||||
end)
|
||||
|
||||
{:stop, character_id, duration_seconds}
|
||||
else
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(60)
|
||||
)
|
||||
|> Enum.reduce([], fn result, acc ->
|
||||
case result do
|
||||
{:ok, {:stop, character_id, duration}} ->
|
||||
[{character_id, duration} | acc]
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
if length(inactive_characters) > 0 do
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Garbage collection found #{length(inactive_characters)} inactive characters to stop"
|
||||
end)
|
||||
|
||||
# Emit telemetry for garbage collection
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :character, :tracker, :garbage_collection],
|
||||
%{inactive_count: length(inactive_characters), total_tracked: length(characters)},
|
||||
%{character_ids: Enum.map(inactive_characters, fn {id, _} -> id end)}
|
||||
)
|
||||
end
|
||||
|
||||
inactive_characters
|
||||
|> Enum.each(fn {character_id, _duration} ->
|
||||
Process.send_after(self(), {:stop_track, character_id}, 100)
|
||||
end)
|
||||
|
||||
state
|
||||
@@ -226,9 +327,22 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
) do
|
||||
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
|
||||
|
||||
WandererApp.Cache.lookup!("character_untrack_queue", [])
|
||||
untrack_queue = WandererApp.Cache.lookup!("character_untrack_queue", [])
|
||||
|
||||
if length(untrack_queue) > 0 do
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Processing untrack queue: #{length(untrack_queue)} character-map pairs"
|
||||
end)
|
||||
end
|
||||
|
||||
untrack_queue
|
||||
|> Task.async_stream(
|
||||
fn {map_id, character_id} ->
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Untracking character #{character_id} from map #{map_id} - " <>
|
||||
"reason: character no longer present on map"
|
||||
end)
|
||||
|
||||
remove_from_untrack_queue(map_id, character_id)
|
||||
|
||||
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
|
||||
@@ -255,12 +369,36 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
|
||||
WandererApp.Character.update_character_state(character_id, character_state)
|
||||
WandererApp.Map.Server.Impl.broadcast!(map_id, :untrack_character, character_id)
|
||||
|
||||
# Emit telemetry for untrack event
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :character, :tracker, :untracked_from_map],
|
||||
%{system_time: System.system_time()},
|
||||
%{character_id: character_id, map_id: map_id, reason: :presence_left}
|
||||
)
|
||||
|
||||
{:ok, character_id, map_id}
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(30)
|
||||
)
|
||||
|> Enum.each(fn _result -> :ok end)
|
||||
|> Enum.each(fn result ->
|
||||
case result do
|
||||
{:ok, {:ok, character_id, map_id}} ->
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Successfully untracked character #{character_id} from map #{map_id}"
|
||||
end)
|
||||
|
||||
{:exit, reason} ->
|
||||
Logger.warning(fn ->
|
||||
"[TrackerManager] Untrack task exited with reason: #{inspect(reason)}"
|
||||
end)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
|
||||
state
|
||||
end
|
||||
@@ -268,9 +406,17 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
def handle_info({:stop_track, character_id}, state) do
|
||||
if not WandererApp.Cache.has_key?("character:#{character_id}:is_stop_tracking") do
|
||||
WandererApp.Cache.insert("character:#{character_id}:is_stop_tracking", true)
|
||||
Logger.debug(fn -> "Stopping character tracker: #{inspect(character_id)}" end)
|
||||
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Executing stop_track for character #{character_id}"
|
||||
end)
|
||||
|
||||
stop_tracking(state, character_id)
|
||||
WandererApp.Cache.delete("character:#{character_id}:is_stop_tracking")
|
||||
else
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Character #{character_id} already being stopped, skipping duplicate request"
|
||||
end)
|
||||
end
|
||||
|
||||
state
|
||||
@@ -279,7 +425,9 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
def track_character(character_id, opts) do
|
||||
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
|
||||
false <- Enum.member?(characters, character_id) do
|
||||
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Starting tracker for character #{character_id}"
|
||||
end)
|
||||
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"tracked_characters",
|
||||
@@ -312,7 +460,30 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
character_id,
|
||||
%{opts: opts}
|
||||
])
|
||||
|
||||
# Emit telemetry for tracker start
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :character, :tracker, :started],
|
||||
%{count: 1, system_time: System.system_time()},
|
||||
%{character_id: character_id}
|
||||
)
|
||||
else
|
||||
true ->
|
||||
Logger.debug(fn ->
|
||||
"[TrackerManager] Character #{character_id} already being tracked"
|
||||
end)
|
||||
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"track_characters_queue",
|
||||
[],
|
||||
fn existing ->
|
||||
existing
|
||||
|> Enum.reject(fn c_id -> c_id == character_id end)
|
||||
end
|
||||
)
|
||||
|
||||
WandererApp.Cache.delete("#{character_id}:track_requested")
|
||||
|
||||
_ ->
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"track_characters_queue",
|
||||
|
||||
@@ -8,7 +8,8 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
:tracked_ids,
|
||||
:uuid,
|
||||
:characters,
|
||||
server_online: true
|
||||
server_online: false,
|
||||
last_location_duration: 0
|
||||
]
|
||||
|
||||
@name __MODULE__
|
||||
@@ -19,13 +20,20 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
@update_location_interval :timer.seconds(1)
|
||||
@update_online_interval :timer.seconds(30)
|
||||
@check_offline_characters_interval :timer.minutes(5)
|
||||
@check_online_errors_interval :timer.minutes(1)
|
||||
@check_ship_errors_interval :timer.minutes(1)
|
||||
@check_location_errors_interval :timer.minutes(1)
|
||||
@update_ship_interval :timer.seconds(2)
|
||||
@update_info_interval :timer.minutes(2)
|
||||
@update_wallet_interval :timer.minutes(10)
|
||||
|
||||
# Per-operation concurrency limits
|
||||
# Location updates are critical and need high concurrency (100 chars in ~200ms)
|
||||
# Note: This is fetched at runtime since it's configured via runtime.exs
|
||||
defp location_concurrency do
|
||||
Application.get_env(:wanderer_app, :location_concurrency, System.schedulers_online() * 12)
|
||||
end
|
||||
|
||||
# Other operations can use lower concurrency
|
||||
@standard_concurrency System.schedulers_online() * 2
|
||||
|
||||
@logger Application.compile_env(:wanderer_app, :logger)
|
||||
|
||||
def new(), do: __struct__()
|
||||
@@ -109,17 +117,23 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
"server_status"
|
||||
)
|
||||
|
||||
Process.send_after(self(), :update_online, 100)
|
||||
Process.send_after(self(), :check_online_errors, :timer.seconds(60))
|
||||
Process.send_after(self(), :check_ship_errors, :timer.seconds(90))
|
||||
Process.send_after(self(), :check_location_errors, :timer.seconds(120))
|
||||
# Stagger pool startups to distribute load across multiple pools
|
||||
# Critical location updates get minimal stagger (0-500ms)
|
||||
# Other operations get wider stagger (0-10s) to reduce thundering herd
|
||||
location_stagger = :rand.uniform(500)
|
||||
online_stagger = :rand.uniform(10_000)
|
||||
ship_stagger = :rand.uniform(10_000)
|
||||
info_stagger = :rand.uniform(60_000)
|
||||
|
||||
Process.send_after(self(), :update_online, 100 + online_stagger)
|
||||
Process.send_after(self(), :update_location, 300 + location_stagger)
|
||||
Process.send_after(self(), :update_ship, 500 + ship_stagger)
|
||||
Process.send_after(self(), :update_info, 1500 + info_stagger)
|
||||
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
|
||||
Process.send_after(self(), :update_location, 300)
|
||||
Process.send_after(self(), :update_ship, 500)
|
||||
Process.send_after(self(), :update_info, 1500)
|
||||
|
||||
if WandererApp.Env.wallet_tracking_enabled?() do
|
||||
Process.send_after(self(), :update_wallet, 1000)
|
||||
wallet_stagger = :rand.uniform(120_000)
|
||||
Process.send_after(self(), :update_wallet, 1000 + wallet_stagger)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
@@ -169,7 +183,7 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
fn character_id ->
|
||||
WandererApp.Character.Tracker.update_online(character_id)
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
max_concurrency: @standard_concurrency,
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(5)
|
||||
)
|
||||
@@ -232,7 +246,7 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
WandererApp.Character.Tracker.check_offline(character_id)
|
||||
end,
|
||||
timeout: :timer.seconds(15),
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
max_concurrency: @standard_concurrency,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.each(fn
|
||||
@@ -250,126 +264,6 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
:check_online_errors,
|
||||
%{
|
||||
characters: characters
|
||||
} =
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :check_online_errors, @check_online_errors_interval)
|
||||
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.TaskWrapper.start_link(
|
||||
WandererApp.Character.Tracker,
|
||||
:check_online_errors,
|
||||
[
|
||||
character_id
|
||||
]
|
||||
)
|
||||
end,
|
||||
timeout: :timer.seconds(15),
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.each(fn
|
||||
{:ok, _result} -> :ok
|
||||
error -> @logger.error("Error in check_online_errors: #{inspect(error)}")
|
||||
end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] check_online_errors => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
:check_ship_errors,
|
||||
%{
|
||||
characters: characters
|
||||
} =
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :check_ship_errors, @check_ship_errors_interval)
|
||||
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.TaskWrapper.start_link(
|
||||
WandererApp.Character.Tracker,
|
||||
:check_ship_errors,
|
||||
[
|
||||
character_id
|
||||
]
|
||||
)
|
||||
end,
|
||||
timeout: :timer.seconds(15),
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.each(fn
|
||||
{:ok, _result} -> :ok
|
||||
error -> @logger.error("Error in check_ship_errors: #{inspect(error)}")
|
||||
end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] check_ship_errors => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
:check_location_errors,
|
||||
%{
|
||||
characters: characters
|
||||
} =
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :check_location_errors, @check_location_errors_interval)
|
||||
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.TaskWrapper.start_link(
|
||||
WandererApp.Character.Tracker,
|
||||
:check_location_errors,
|
||||
[
|
||||
character_id
|
||||
]
|
||||
)
|
||||
end,
|
||||
timeout: :timer.seconds(15),
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.each(fn
|
||||
{:ok, _result} -> :ok
|
||||
error -> @logger.error("Error in check_location_errors: #{inspect(error)}")
|
||||
end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] check_location_errors => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
:update_location,
|
||||
%{
|
||||
@@ -380,26 +274,52 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
) do
|
||||
Process.send_after(self(), :update_location, @update_location_interval)
|
||||
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.Character.Tracker.update_location(character_id)
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
max_concurrency: location_concurrency(),
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(5)
|
||||
)
|
||||
|> Enum.each(fn _result -> :ok end)
|
||||
|
||||
# Emit telemetry for location update performance
|
||||
duration = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :tracker_pool, :location_update],
|
||||
%{duration: duration, character_count: length(characters)},
|
||||
%{pool_uuid: state.uuid}
|
||||
)
|
||||
|
||||
# Warn if location updates are falling behind (taking > 800ms for 100 chars)
|
||||
if duration > 2000 do
|
||||
Logger.warning(
|
||||
"[Tracker Pool] Location updates falling behind: #{duration}ms for #{length(characters)} chars (pool: #{state.uuid})"
|
||||
)
|
||||
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :tracker_pool, :location_lag],
|
||||
%{duration: duration, character_count: length(characters)},
|
||||
%{pool_uuid: state.uuid}
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, %{state | last_location_duration: duration}}
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] update_location => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
@@ -415,32 +335,48 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
:update_ship,
|
||||
%{
|
||||
characters: characters,
|
||||
server_online: true
|
||||
server_online: true,
|
||||
last_location_duration: location_duration
|
||||
} =
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :update_ship, @update_ship_interval)
|
||||
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.Character.Tracker.update_ship(character_id)
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(5)
|
||||
# Backpressure: Skip ship updates if location updates are falling behind
|
||||
if location_duration > 1000 do
|
||||
Logger.debug(
|
||||
"[Tracker Pool] Skipping ship update due to location lag (#{location_duration}ms)"
|
||||
)
|
||||
|> Enum.each(fn _result -> :ok end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] update_ship => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :tracker_pool, :ship_skipped],
|
||||
%{count: 1},
|
||||
%{pool_uuid: state.uuid, reason: :location_lag}
|
||||
)
|
||||
|
||||
{:noreply, state}
|
||||
else
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.Character.Tracker.update_ship(character_id)
|
||||
end,
|
||||
max_concurrency: @standard_concurrency,
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(5)
|
||||
)
|
||||
|> Enum.each(fn _result -> :ok end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] update_ship => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
@@ -456,35 +392,51 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
:update_info,
|
||||
%{
|
||||
characters: characters,
|
||||
server_online: true
|
||||
server_online: true,
|
||||
last_location_duration: location_duration
|
||||
} =
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :update_info, @update_info_interval)
|
||||
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.Character.Tracker.update_info(character_id)
|
||||
end,
|
||||
timeout: :timer.seconds(15),
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
on_timeout: :kill_task
|
||||
# Backpressure: Skip info updates if location updates are severely falling behind
|
||||
if location_duration > 1500 do
|
||||
Logger.debug(
|
||||
"[Tracker Pool] Skipping info update due to location lag (#{location_duration}ms)"
|
||||
)
|
||||
|> Enum.each(fn
|
||||
{:ok, _result} -> :ok
|
||||
error -> Logger.error("Error in update_info: #{inspect(error)}")
|
||||
end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] update_info => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :tracker_pool, :info_skipped],
|
||||
%{count: 1},
|
||||
%{pool_uuid: state.uuid, reason: :location_lag}
|
||||
)
|
||||
|
||||
{:noreply, state}
|
||||
else
|
||||
try do
|
||||
characters
|
||||
|> Task.async_stream(
|
||||
fn character_id ->
|
||||
WandererApp.Character.Tracker.update_info(character_id)
|
||||
end,
|
||||
timeout: :timer.seconds(15),
|
||||
max_concurrency: @standard_concurrency,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.each(fn
|
||||
{:ok, _result} -> :ok
|
||||
error -> Logger.error("Error in update_info: #{inspect(error)}")
|
||||
end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[Tracker Pool] update_info => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
@@ -513,7 +465,7 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
WandererApp.Character.Tracker.update_wallet(character_id)
|
||||
end,
|
||||
timeout: :timer.minutes(5),
|
||||
max_concurrency: System.schedulers_online() * 4,
|
||||
max_concurrency: @standard_concurrency,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.each(fn
|
||||
|
||||
@@ -52,7 +52,7 @@ defmodule WandererApp.Character.TrackerPoolDynamicSupervisor do
|
||||
|
||||
defp get_available_pool([]), do: nil
|
||||
|
||||
defp get_available_pool([{pid, uuid} | pools]) do
|
||||
defp get_available_pool([{_pid, uuid} | pools]) do
|
||||
case Registry.lookup(@unique_registry, Module.concat(WandererApp.Character.TrackerPool, uuid)) do
|
||||
[] ->
|
||||
nil
|
||||
@@ -62,8 +62,8 @@ defmodule WandererApp.Character.TrackerPoolDynamicSupervisor do
|
||||
nil ->
|
||||
get_available_pool(pools)
|
||||
|
||||
pid ->
|
||||
pid
|
||||
pool_pid ->
|
||||
pool_pid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,8 +114,88 @@ defmodule WandererApp.Character.TrackingUtils do
|
||||
|
||||
# Private implementation of update character tracking
|
||||
defp do_update_character_tracking(character, map_id, track, caller_pid) do
|
||||
WandererApp.MapCharacterSettingsRepo.get(map_id, character.id)
|
||||
|> case do
|
||||
# First check current tracking state to avoid unnecessary permission checks
|
||||
current_settings = WandererApp.MapCharacterSettingsRepo.get(map_id, character.id)
|
||||
|
||||
case {track, current_settings} do
|
||||
# Already tracked and wants to stay tracked - no permission check needed
|
||||
{true, {:ok, %{tracked: true} = settings}} ->
|
||||
do_update_character_tracking_impl(character, map_id, track, caller_pid, {:ok, settings})
|
||||
|
||||
# Wants to enable tracking - check permissions first
|
||||
{true, settings_result} ->
|
||||
case check_character_tracking_permission(character, map_id) do
|
||||
{:ok, :allowed} ->
|
||||
do_update_character_tracking_impl(character, map_id, track, caller_pid, settings_result)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"[CharacterTracking] Character #{character.id} cannot be tracked on map #{map_id}: #{reason}"
|
||||
)
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
# Untracking is always allowed
|
||||
{false, settings_result} ->
|
||||
do_update_character_tracking_impl(character, map_id, track, caller_pid, settings_result)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if a character has permission to be tracked on a map
|
||||
defp check_character_tracking_permission(character, map_id) do
|
||||
with {:ok, %{acls: acls, owner_id: owner_id}} <-
|
||||
WandererApp.MapRepo.get(map_id,
|
||||
acls: [
|
||||
:owner_id,
|
||||
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
|
||||
]
|
||||
) do
|
||||
# Check if character is the map owner
|
||||
if character.id == owner_id do
|
||||
{:ok, :allowed}
|
||||
else
|
||||
# Check if character belongs to same user as owner (Option 3 check)
|
||||
case check_same_user_as_owner(character, owner_id) do
|
||||
true ->
|
||||
{:ok, :allowed}
|
||||
|
||||
false ->
|
||||
# Check ACL-based permissions
|
||||
[character_permissions] =
|
||||
WandererApp.Permissions.check_characters_access([character], acls)
|
||||
|
||||
map_permissions = WandererApp.Permissions.get_permissions(character_permissions)
|
||||
|
||||
if map_permissions.track_character and map_permissions.view_system do
|
||||
{:ok, :allowed}
|
||||
else
|
||||
{:error,
|
||||
"Character does not have tracking permission on this map. Please add the character to a map access list or ensure you are the map owner."}
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, _} ->
|
||||
{:error, "Failed to verify map permissions"}
|
||||
end
|
||||
end
|
||||
|
||||
# Check if character belongs to the same user as the map owner
|
||||
defp check_same_user_as_owner(_character, nil), do: false
|
||||
|
||||
defp check_same_user_as_owner(character, owner_id) do
|
||||
case WandererApp.Character.get_character(owner_id) do
|
||||
{:ok, owner_character} ->
|
||||
character.user_id != nil and character.user_id == owner_character.user_id
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp do_update_character_tracking_impl(character, map_id, track, caller_pid, settings_result) do
|
||||
case settings_result do
|
||||
# Untracking flow
|
||||
{:ok, %{tracked: true} = existing_settings} ->
|
||||
if not track do
|
||||
|
||||
@@ -12,7 +12,7 @@ defmodule WandererApp.Character.TransactionsTracker.Impl do
|
||||
total_balance: 0,
|
||||
transactions: [],
|
||||
retries: 5,
|
||||
server_online: true,
|
||||
server_online: false,
|
||||
status: :started
|
||||
]
|
||||
|
||||
@@ -75,7 +75,7 @@ defmodule WandererApp.Character.TransactionsTracker.Impl do
|
||||
|
||||
def handle_event(
|
||||
:update_corp_wallets,
|
||||
%{character: character} = state
|
||||
%{character: character, server_online: true} = state
|
||||
) do
|
||||
Process.send_after(self(), :update_corp_wallets, @update_interval)
|
||||
|
||||
@@ -88,26 +88,26 @@ defmodule WandererApp.Character.TransactionsTracker.Impl do
|
||||
:update_corp_wallets,
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :update_corp_wallets, :timer.seconds(15))
|
||||
Process.send_after(self(), :update_corp_wallets, @update_interval)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
:check_wallets,
|
||||
%{wallets: []} = state
|
||||
%{character: character, wallets: wallets, server_online: true} = state
|
||||
) do
|
||||
Process.send_after(self(), :check_wallets, :timer.seconds(5))
|
||||
Process.send_after(self(), :check_wallets, @update_interval)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
:check_wallets,
|
||||
%{character: character, wallets: wallets} = state
|
||||
) do
|
||||
check_wallets(wallets, character)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
:check_wallets,
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :check_wallets, @update_interval)
|
||||
|
||||
state
|
||||
|
||||
@@ -17,7 +17,6 @@ defmodule WandererApp.Env do
|
||||
def invites(), do: get_key(:invites, false)
|
||||
|
||||
def map_subscriptions_enabled?(), do: get_key(:map_subscriptions_enabled, false)
|
||||
def websocket_events_enabled?(), do: get_key(:websocket_events_enabled, false)
|
||||
def public_api_disabled?(), do: get_key(:public_api_disabled, false)
|
||||
|
||||
@decorate cacheable(
|
||||
|
||||
@@ -2,6 +2,8 @@ defmodule WandererApp.Esi do
|
||||
@moduledoc group: :esi
|
||||
|
||||
defdelegate get_server_status, to: WandererApp.Esi.ApiClient
|
||||
defdelegate get_group_info(group_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
defdelegate get_type_info(type_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
defdelegate get_alliance_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
defdelegate get_corporation_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
defdelegate get_character_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
|
||||
@@ -17,6 +17,17 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
@logger Application.compile_env(:wanderer_app, :logger)
|
||||
|
||||
# Pool selection for different operation types
|
||||
# Character tracking operations use dedicated high-capacity pool
|
||||
@character_tracking_pool WandererApp.Finch.ESI.CharacterTracking
|
||||
# General ESI operations use standard pool
|
||||
@general_pool WandererApp.Finch.ESI.General
|
||||
|
||||
# Helper function to get Req options with appropriate Finch pool
|
||||
defp req_options_for_pool(pool) do
|
||||
[base_url: "https://esi.evetech.net", finch: pool]
|
||||
end
|
||||
|
||||
def get_server_status, do: do_get("/status", [], @cache_opts)
|
||||
|
||||
def set_autopilot_waypoint(add_to_beginning, clear_other_waypoints, destination_id, opts \\ []),
|
||||
@@ -38,10 +49,13 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
do:
|
||||
do_post_esi(
|
||||
"/characters/affiliation/",
|
||||
json: character_eve_ids,
|
||||
params: %{
|
||||
datasource: "tranquility"
|
||||
}
|
||||
[
|
||||
json: character_eve_ids,
|
||||
params: %{
|
||||
datasource: "tranquility"
|
||||
}
|
||||
],
|
||||
@character_tracking_pool
|
||||
)
|
||||
|
||||
def get_routes_custom(hubs, origin, params),
|
||||
@@ -116,7 +130,33 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "info-#{eve_id}",
|
||||
key: "group-info-#{group_id}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_group_info(group_id, opts),
|
||||
do:
|
||||
do_get(
|
||||
"/universe/groups/#{group_id}/",
|
||||
opts,
|
||||
@cache_opts
|
||||
)
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "type-info-#{type_id}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_type_info(type_id, opts),
|
||||
do:
|
||||
do_get(
|
||||
"/universe/types/#{type_id}/",
|
||||
opts,
|
||||
@cache_opts
|
||||
)
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "alliance-info-#{eve_id}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_alliance_info(eve_id, opts \\ []) do
|
||||
@@ -137,7 +177,7 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "info-#{eve_id}",
|
||||
key: "corporation-info-#{eve_id}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_corporation_info(eve_id, opts \\ []) do
|
||||
@@ -150,7 +190,7 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "info-#{eve_id}",
|
||||
key: "character-info-#{eve_id}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_character_info(eve_id, opts \\ []) do
|
||||
@@ -203,8 +243,17 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
do: get_character_auth_data(character_eve_id, "ship", opts ++ @cache_opts)
|
||||
|
||||
def search(character_eve_id, opts \\ []) do
|
||||
search_val = to_string(opts[:params][:search] || "")
|
||||
categories_val = to_string(opts[:params][:categories] || "character,alliance,corporation")
|
||||
params = Keyword.get(opts, :params, %{}) |> Map.new()
|
||||
|
||||
search_val =
|
||||
to_string(Map.get(params, :search) || Map.get(params, "search") || "")
|
||||
|
||||
categories_val =
|
||||
to_string(
|
||||
Map.get(params, :categories) ||
|
||||
Map.get(params, "categories") ||
|
||||
"character,alliance,corporation"
|
||||
)
|
||||
|
||||
query_params = [
|
||||
{"search", search_val},
|
||||
@@ -220,7 +269,7 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "search-#{character_eve_id}-#{categories_val}-#{search_val |> Slug.slugify()}",
|
||||
key: "search-#{character_eve_id}-#{categories_val}-#{Base.encode64(search_val)}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
defp get_search(character_eve_id, search_val, categories_val, merged_opts) do
|
||||
@@ -254,14 +303,18 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
character_id = opts |> Keyword.get(:character_id, nil)
|
||||
|
||||
# Use character tracking pool for character operations
|
||||
pool = @character_tracking_pool
|
||||
|
||||
if not is_access_token_expired?(character_id) do
|
||||
do_get(
|
||||
path,
|
||||
auth_opts,
|
||||
opts |> with_refresh_token()
|
||||
opts |> with_refresh_token(),
|
||||
pool
|
||||
)
|
||||
else
|
||||
do_get_retry(path, auth_opts, opts |> with_refresh_token())
|
||||
do_get_retry(path, auth_opts, opts |> with_refresh_token(), :forbidden, pool)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -295,19 +348,19 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
defp with_cache_opts(opts),
|
||||
do: opts |> Keyword.merge(@cache_opts) |> Keyword.merge(cache_dir: System.tmp_dir!())
|
||||
|
||||
defp do_get(path, api_opts \\ [], opts \\ []) do
|
||||
defp do_get(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
|
||||
case Cachex.get(:api_cache, path) do
|
||||
{:ok, cached_data} when not is_nil(cached_data) ->
|
||||
{:ok, cached_data}
|
||||
|
||||
_ ->
|
||||
do_get_request(path, api_opts, opts)
|
||||
do_get_request(path, api_opts, opts, pool)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_get_request(path, api_opts \\ [], opts \\ []) do
|
||||
defp do_get_request(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
|
||||
try do
|
||||
@req_esi_options
|
||||
req_options_for_pool(pool)
|
||||
|> Req.new()
|
||||
|> Req.get(
|
||||
api_opts
|
||||
@@ -398,12 +451,49 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
{:ok, %{status: status, headers: headers}} ->
|
||||
{:error, "Unexpected status: #{status}"}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:error, %Mint.TransportError{reason: :timeout}} ->
|
||||
# Emit telemetry for pool timeout
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :finch, :pool_timeout],
|
||||
%{count: 1},
|
||||
%{method: "GET", path: path, pool: pool}
|
||||
)
|
||||
|
||||
{:error, :pool_timeout}
|
||||
|
||||
{:error, reason} ->
|
||||
# Check if this is a Finch pool error
|
||||
if is_exception(reason) and
|
||||
Exception.message(reason) =~ "unable to provide a connection" do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :finch, :pool_exhausted],
|
||||
%{count: 1},
|
||||
%{method: "GET", path: path, pool: pool}
|
||||
)
|
||||
end
|
||||
|
||||
{:error, "Request failed"}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error(Exception.message(e))
|
||||
error_msg = Exception.message(e)
|
||||
|
||||
# Emit telemetry for pool exhaustion errors
|
||||
if error_msg =~ "unable to provide a connection" do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :finch, :pool_exhausted],
|
||||
%{count: 1},
|
||||
%{method: "GET", path: path, pool: pool}
|
||||
)
|
||||
|
||||
Logger.error("FINCH_POOL_EXHAUSTED: #{error_msg}",
|
||||
method: "GET",
|
||||
path: path,
|
||||
pool: inspect(pool)
|
||||
)
|
||||
else
|
||||
Logger.error(error_msg)
|
||||
end
|
||||
|
||||
{:error, "Request failed"}
|
||||
end
|
||||
@@ -492,13 +582,13 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
end
|
||||
end
|
||||
|
||||
defp do_post_esi(url, opts) do
|
||||
defp do_post_esi(url, opts, pool \\ @general_pool) do
|
||||
try do
|
||||
req_opts =
|
||||
(opts |> with_user_agent_opts() |> Keyword.merge(@retry_opts)) ++
|
||||
[params: opts[:params] || []]
|
||||
|
||||
Req.new(@req_esi_options ++ req_opts)
|
||||
Req.new(req_options_for_pool(pool) ++ req_opts)
|
||||
|> Req.post(url: url)
|
||||
|> case do
|
||||
{:ok, %{status: status, body: body}} when status in [200, 201] ->
|
||||
@@ -576,18 +666,55 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
{:ok, %{status: status}} ->
|
||||
{:error, "Unexpected status: #{status}"}
|
||||
|
||||
{:error, %Mint.TransportError{reason: :timeout}} ->
|
||||
# Emit telemetry for pool timeout
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :finch, :pool_timeout],
|
||||
%{count: 1},
|
||||
%{method: "POST_ESI", path: url, pool: pool}
|
||||
)
|
||||
|
||||
{:error, :pool_timeout}
|
||||
|
||||
{:error, reason} ->
|
||||
# Check if this is a Finch pool error
|
||||
if is_exception(reason) and
|
||||
Exception.message(reason) =~ "unable to provide a connection" do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :finch, :pool_exhausted],
|
||||
%{count: 1},
|
||||
%{method: "POST_ESI", path: url, pool: pool}
|
||||
)
|
||||
end
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
@logger.error(Exception.message(e))
|
||||
error_msg = Exception.message(e)
|
||||
|
||||
# Emit telemetry for pool exhaustion errors
|
||||
if error_msg =~ "unable to provide a connection" do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :finch, :pool_exhausted],
|
||||
%{count: 1},
|
||||
%{method: "POST_ESI", path: url, pool: pool}
|
||||
)
|
||||
|
||||
@logger.error("FINCH_POOL_EXHAUSTED: #{error_msg}",
|
||||
method: "POST_ESI",
|
||||
path: url,
|
||||
pool: inspect(pool)
|
||||
)
|
||||
else
|
||||
@logger.error(error_msg)
|
||||
end
|
||||
|
||||
{:error, "Request failed"}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_get_retry(path, api_opts, opts, status \\ :forbidden) do
|
||||
defp do_get_retry(path, api_opts, opts, status \\ :forbidden, pool \\ @general_pool) do
|
||||
refresh_token? = opts |> Keyword.get(:refresh_token?, false)
|
||||
retry_count = opts |> Keyword.get(:retry_count, 0)
|
||||
character_id = opts |> Keyword.get(:character_id, nil)
|
||||
@@ -602,7 +729,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
do_get(
|
||||
path,
|
||||
api_opts |> Keyword.merge(auth_opts),
|
||||
opts |> Keyword.merge(retry_count: retry_count + 1)
|
||||
opts |> Keyword.merge(retry_count: retry_count + 1),
|
||||
pool
|
||||
)
|
||||
|
||||
{:error, _error} ->
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule WandererApp.ExternalEvents do
|
||||
@moduledoc """
|
||||
External event system for SSE and webhook delivery.
|
||||
|
||||
This system is completely separate from the internal Phoenix PubSub
|
||||
This system is completely separate from the internal Phoenix PubSub
|
||||
event system and does NOT modify any existing event flows.
|
||||
|
||||
External events are delivered to:
|
||||
@@ -72,20 +72,12 @@ defmodule WandererApp.ExternalEvents do
|
||||
|
||||
# Check if MapEventRelay is alive before sending
|
||||
if Process.whereis(MapEventRelay) do
|
||||
try do
|
||||
# Use call with timeout instead of cast for better error handling
|
||||
GenServer.call(MapEventRelay, {:deliver_event, event}, 5000)
|
||||
:ok
|
||||
catch
|
||||
:exit, {:timeout, _} ->
|
||||
Logger.error("Timeout delivering event to MapEventRelay for map #{map_id}")
|
||||
{:error, :timeout}
|
||||
|
||||
:exit, reason ->
|
||||
Logger.error("Failed to deliver event to MapEventRelay: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
# Use cast for async delivery to avoid blocking the caller
|
||||
# This is critical for performance in hot paths (character updates)
|
||||
GenServer.cast(MapEventRelay, {:deliver_event, event})
|
||||
:ok
|
||||
else
|
||||
Logger.debug(fn -> "MapEventRelay not available for event delivery (map: #{map_id})" end)
|
||||
{:error, :relay_not_available}
|
||||
end
|
||||
else
|
||||
|
||||
@@ -20,6 +20,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
| :character_added
|
||||
| :character_removed
|
||||
| :character_updated
|
||||
| :characters_updated
|
||||
| :map_kill
|
||||
| :acl_member_added
|
||||
| :acl_member_removed
|
||||
@@ -42,50 +43,6 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
|
||||
defstruct [:id, :map_id, :type, :payload, :timestamp]
|
||||
|
||||
@doc """
|
||||
Creates a new external event with ULID for ordering.
|
||||
|
||||
Validates that the event_type is supported before creating the event.
|
||||
"""
|
||||
@spec new(String.t(), event_type(), map()) :: t() | {:error, :invalid_event_type}
|
||||
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
|
||||
if valid_event_type?(event_type) do
|
||||
%__MODULE__{
|
||||
id: Ecto.ULID.generate(System.system_time(:millisecond)),
|
||||
map_id: map_id,
|
||||
type: event_type,
|
||||
payload: payload,
|
||||
timestamp: DateTime.utc_now()
|
||||
}
|
||||
else
|
||||
raise ArgumentError,
|
||||
"Invalid event type: #{inspect(event_type)}. Must be one of: #{supported_event_types() |> Enum.map(&to_string/1) |> Enum.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts an event to JSON format for delivery.
|
||||
"""
|
||||
@spec to_json(t()) :: map()
|
||||
def to_json(%__MODULE__{} = event) do
|
||||
%{
|
||||
"id" => event.id,
|
||||
"type" => to_string(event.type),
|
||||
"map_id" => event.map_id,
|
||||
"timestamp" => DateTime.to_iso8601(event.timestamp),
|
||||
"payload" => serialize_payload(event.payload)
|
||||
}
|
||||
end
|
||||
|
||||
# Convert Ash structs and other complex types to plain maps
|
||||
defp serialize_payload(payload) when is_struct(payload) do
|
||||
serialize_payload(payload, MapSet.new())
|
||||
end
|
||||
|
||||
defp serialize_payload(payload) when is_map(payload) do
|
||||
serialize_payload(payload, MapSet.new())
|
||||
end
|
||||
|
||||
# Define allowlisted fields for different struct types
|
||||
@system_fields [
|
||||
:id,
|
||||
@@ -133,6 +90,73 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
]
|
||||
@signature_fields [:id, :signature_id, :name, :type, :group]
|
||||
|
||||
@supported_event_types [
|
||||
:add_system,
|
||||
:deleted_system,
|
||||
:system_renamed,
|
||||
:system_metadata_changed,
|
||||
:signatures_updated,
|
||||
:signature_added,
|
||||
:signature_removed,
|
||||
:connection_added,
|
||||
:connection_removed,
|
||||
:connection_updated,
|
||||
:character_added,
|
||||
:character_removed,
|
||||
:character_updated,
|
||||
:characters_updated,
|
||||
:map_kill,
|
||||
:acl_member_added,
|
||||
:acl_member_removed,
|
||||
:acl_member_updated,
|
||||
:rally_point_added,
|
||||
:rally_point_removed
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new external event with ULID for ordering.
|
||||
|
||||
Validates that the event_type is supported before creating the event.
|
||||
"""
|
||||
@spec new(String.t(), event_type(), map()) :: t() | {:error, :invalid_event_type}
|
||||
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
|
||||
if valid_event_type?(event_type) do
|
||||
%__MODULE__{
|
||||
id: Ecto.ULID.generate(System.system_time(:millisecond)),
|
||||
map_id: map_id,
|
||||
type: event_type,
|
||||
payload: payload,
|
||||
timestamp: DateTime.utc_now()
|
||||
}
|
||||
else
|
||||
raise ArgumentError,
|
||||
"Invalid event type: #{inspect(event_type)}. Must be one of: #{supported_event_types() |> Enum.map(&to_string/1) |> Enum.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts an event to JSON format for delivery.
|
||||
"""
|
||||
@spec to_json(t()) :: map()
|
||||
def to_json(%__MODULE__{} = event) do
|
||||
%{
|
||||
"id" => event.id,
|
||||
"type" => to_string(event.type),
|
||||
"map_id" => event.map_id,
|
||||
"timestamp" => DateTime.to_iso8601(event.timestamp),
|
||||
"payload" => serialize_payload(event.payload)
|
||||
}
|
||||
end
|
||||
|
||||
# Convert Ash structs and other complex types to plain maps
|
||||
defp serialize_payload(payload) when is_struct(payload) do
|
||||
serialize_payload(payload, MapSet.new())
|
||||
end
|
||||
|
||||
defp serialize_payload(payload) when is_map(payload) do
|
||||
serialize_payload(payload, MapSet.new())
|
||||
end
|
||||
|
||||
# Overloaded versions with visited tracking
|
||||
defp serialize_payload(payload, visited) when is_struct(payload) do
|
||||
# Check for circular reference
|
||||
@@ -193,29 +217,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
Returns all supported event types.
|
||||
"""
|
||||
@spec supported_event_types() :: [event_type()]
|
||||
def supported_event_types do
|
||||
[
|
||||
:add_system,
|
||||
:deleted_system,
|
||||
:system_renamed,
|
||||
:system_metadata_changed,
|
||||
:signatures_updated,
|
||||
:signature_added,
|
||||
:signature_removed,
|
||||
:connection_added,
|
||||
:connection_removed,
|
||||
:connection_updated,
|
||||
:character_added,
|
||||
:character_removed,
|
||||
:character_updated,
|
||||
:map_kill,
|
||||
:acl_member_added,
|
||||
:acl_member_removed,
|
||||
:acl_member_updated,
|
||||
:rally_point_added,
|
||||
:rally_point_removed
|
||||
]
|
||||
end
|
||||
def supported_event_types, do: @supported_event_types
|
||||
|
||||
@doc """
|
||||
Validates an event type.
|
||||
|
||||
@@ -82,16 +82,9 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
|
||||
|
||||
@impl true
|
||||
def handle_call({:deliver_event, %Event{} = event}, _from, state) do
|
||||
# Log ACL events at info level for debugging
|
||||
if event.type in [:acl_member_added, :acl_member_removed, :acl_member_updated] do
|
||||
Logger.debug(fn ->
|
||||
"MapEventRelay received :deliver_event (call) for map #{event.map_id}, type: #{event.type}"
|
||||
end)
|
||||
else
|
||||
Logger.debug(fn ->
|
||||
"MapEventRelay received :deliver_event (call) for map #{event.map_id}, type: #{event.type}"
|
||||
end)
|
||||
end
|
||||
Logger.debug(fn ->
|
||||
"MapEventRelay received :deliver_event (call) for map #{event.map_id}, type: #{event.type}"
|
||||
end)
|
||||
|
||||
new_state = deliver_single_event(event, state)
|
||||
{:reply, :ok, new_state}
|
||||
@@ -162,26 +155,23 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
|
||||
# 1. Store in ETS for backfill
|
||||
store_event(event, state.ets_table)
|
||||
|
||||
# 2. Convert event to JSON for delivery methods
|
||||
event_json = Event.to_json(event)
|
||||
|
||||
Logger.debug(fn ->
|
||||
"MapEventRelay converted event to JSON: #{inspect(String.slice(inspect(event_json), 0, 200))}..."
|
||||
end)
|
||||
|
||||
# 3. Send to webhook subscriptions via WebhookDispatcher
|
||||
WebhookDispatcher.dispatch_event(event.map_id, event)
|
||||
|
||||
# 4. Broadcast to SSE clients
|
||||
Logger.debug(fn -> "MapEventRelay broadcasting to SSE clients for map #{event.map_id}" end)
|
||||
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
|
||||
case WandererApp.ExternalEvents.SseAccessControl.sse_allowed?(event.map_id) do
|
||||
:ok ->
|
||||
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
|
||||
|
||||
# Emit delivered telemetry
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :external_events, :relay, :delivered],
|
||||
%{count: 1},
|
||||
%{map_id: event.map_id, event_type: event.type}
|
||||
)
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :external_events, :relay, :delivered],
|
||||
%{count: 1},
|
||||
%{map_id: event.map_id, event_type: event.type}
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
%{state | event_count: state.event_count + 1}
|
||||
end
|
||||
|
||||
71
lib/wanderer_app/external_events/sse_access_control.ex
Normal file
71
lib/wanderer_app/external_events/sse_access_control.ex
Normal file
@@ -0,0 +1,71 @@
|
||||
defmodule WandererApp.ExternalEvents.SseAccessControl do
|
||||
@moduledoc """
|
||||
Handles SSE access control checks including subscription validation.
|
||||
|
||||
Note: Community Edition mode is automatically handled by the
|
||||
WandererApp.Map.is_subscription_active?/1 function, which returns
|
||||
{:ok, true} when subscriptions are disabled globally.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Checks if SSE is allowed for a given map.
|
||||
|
||||
Returns:
|
||||
- :ok if SSE is allowed
|
||||
- {:error, reason} if SSE is not allowed
|
||||
|
||||
Checks in order:
|
||||
1. Global SSE enabled (config)
|
||||
2. Map exists
|
||||
3. Map SSE enabled (per-map setting)
|
||||
4. Subscription active (CE mode handled internally)
|
||||
"""
|
||||
def sse_allowed?(map_id) do
|
||||
with :ok <- check_sse_globally_enabled(),
|
||||
{:ok, map} <- fetch_map(map_id),
|
||||
:ok <- check_map_sse_enabled(map),
|
||||
:ok <- check_subscription_or_ce(map_id) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_sse_globally_enabled do
|
||||
if WandererApp.Env.sse_enabled?() do
|
||||
:ok
|
||||
else
|
||||
{:error, :sse_globally_disabled}
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches the map by ID.
|
||||
# Returns {:ok, map} or {:error, :map_not_found}
|
||||
defp fetch_map(map_id) do
|
||||
case WandererApp.Api.Map.by_id(map_id) do
|
||||
{:ok, _map} = result -> result
|
||||
_ -> {:error, :map_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_map_sse_enabled(map) do
|
||||
if map.sse_enabled do
|
||||
:ok
|
||||
else
|
||||
{:error, :sse_disabled_for_map}
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if map has active subscription or if running Community Edition.
|
||||
#
|
||||
# Returns :ok if:
|
||||
# - Community Edition (handled internally by is_subscription_active?/1), OR
|
||||
# - Map has active subscription
|
||||
#
|
||||
# Returns {:error, :subscription_required} if subscription check fails.
|
||||
defp check_subscription_or_ce(map_id) do
|
||||
case WandererApp.Map.is_subscription_active?(map_id) do
|
||||
{:ok, true} -> :ok
|
||||
{:ok, false} -> {:error, :subscription_required}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user