Compare commits
No commits in common. "80a0a16bab1126ce108d112d21633092c9e40666" and "57a2384511fd270e91fa28ff87927cc398c11d2b" have entirely different histories.
80a0a16bab
...
57a2384511
15 changed files with 318 additions and 5809 deletions
95
ARCHITECTURE.md
Normal file
95
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Project Architecture and Data Model
|
||||
|
||||
This document provides an overview of the project files, their roles, and a detailed description of the database architecture and data model.
|
||||
|
||||
## Project Structure
|
||||
|
||||
Root directory:
|
||||
- **Dockerfile**: Defines the Python 3.12-slim image, installs dependencies (FastAPI, Uvicorn, SQLAlchemy, databases, TimescaleDB support), and runs the app.
|
||||
- **docker-compose.yml**: Orchestrates two services:
|
||||
- **dereth-tracker**: The FastAPI application container.
|
||||
- **db**: A TimescaleDB (PostgreSQL 14 + TimescaleDB extension) container for persistent storage.
|
||||
- **README.md**: High-level documentation and usage instructions.
|
||||
- **EVENT_FORMATS.json**: Example JSON payloads for all event types (`telemetry`, `spawn`, `chat`, `rare`).
|
||||
- **db.py**: Legacy SQLite-based storage (telemetry_log & live_state tables, WAL mode, auto-vacuum).
|
||||
- **db_async.py**: Async database definitions for PostgreSQL/TimescaleDB:
|
||||
- Table schemas (SQLAlchemy Core):
|
||||
- `telemetry_events`,
|
||||
- `char_stats`,
|
||||
- `rare_stats`,
|
||||
- `rare_stats_sessions`,
|
||||
- `spawn_events`,
|
||||
- `rare_events`.
|
||||
- `init_db_async()`: Creates tables, enables TimescaleDB extension, and configures a hypertable on `telemetry_events`.
|
||||
- **main.py**: The FastAPI application:
|
||||
- HTTP endpoints: `/debug`, `/live`, `/history`, `/trails`.
|
||||
- WebSocket endpoints: `/ws/position` (plugin data in), `/ws/live` (browser live updates).
|
||||
- Pydantic models: `TelemetrySnapshot`, `SpawnEvent`.
|
||||
- In-memory state: `live_snapshots`, WebSocket connection registries.
|
||||
- **generate_data.py**: Sample WebSocket client that sends synthetic telemetry snapshots.
|
||||
- **alembic/** & **alembic.ini**: Migration tooling for evolving the database schema.
|
||||
- **static/**: Frontend assets (HTML, CSS, JavaScript, images) for the live map UI.
|
||||
- **FIXES.md**, **LESSONSLEARNED.md**, **TODO.md**: Project notes and future work.
|
||||
|
||||
## Database Overview
|
||||
|
||||
### Technology Stack
|
||||
- **PostgreSQL** 14 with **TimescaleDB** extension for time-series optimization.
|
||||
- **databases** library (async) with **SQLAlchemy Core** for schema definitions and queries.
|
||||
- Environment variable: `DATABASE_URL` controls the connection string.
|
||||
|
||||
### Tables and Hypertable
|
||||
1. **telemetry_events** (hypertable)
|
||||
- Columns:
|
||||
- `id`: Integer, primary key.
|
||||
- `character_name` (String), `char_tag` (String, nullable), `session_id` (String, indexed).
|
||||
- `timestamp` (DateTime with TZ, indexed) — partitioning column for the hypertable.
|
||||
- `ew`, `ns`, `z`: Float coordinates.
|
||||
- `kills`, `deaths`, `rares_found`, `prismatic_taper_count`: Integer metrics.
|
||||
- `kills_per_hour`, `onlinetime` (String), `vt_state` (String).
|
||||
- Optional: `mem_mb`, `cpu_pct`, `mem_handles`, `latency_ms`.
|
||||
- Created via `SELECT create_hypertable('telemetry_events', 'timestamp', if_not_exists=>true, create_default_indexes=>false)`.
|
||||
|
||||
2. **char_stats**
|
||||
- Tracks cumulative kills per character.
|
||||
- Columns: `character_name` (PK), `total_kills` (Integer).
|
||||
|
||||
3. **rare_stats**
|
||||
- Tracks total rare spawns per character.
|
||||
- Columns: `character_name` (PK), `total_rares` (Integer).
|
||||
|
||||
4. **rare_stats_sessions**
|
||||
- Tracks rarities per session.
|
||||
- Columns: composite PK `(character_name, session_id)`, `session_rares` (Integer).
|
||||
|
||||
5. **spawn_events**
|
||||
- Records individual mob spawn events for heatmapping.
|
||||
- Columns: `id` (PK), `character_name` (String), `mob` (String), `timestamp` (DateTime), `ew`, `ns`, `z` (Float).
|
||||
- Coordinates (`ew`, `ns`, `z`) can be sent as JSON numbers or strings and are coerced to floats.
|
||||
|
||||
6. **rare_events**
|
||||
- Records each rare spawn event for future heatmaps and analysis.
|
||||
- Columns: `id` (PK), `character_name` (String), `name` (String), `timestamp` (DateTime), `ew`, `ns`, `z` (Float).
|
||||
|
||||
### Initialization and Migrations
|
||||
- On startup (`main.py`), `init_db_async()` is called:
|
||||
1. Creates all tables via SQLAlchemy’s `metadata.create_all()`.
|
||||
2. Enables TimescaleDB extension.
|
||||
3. Converts `telemetry_events` to a hypertable, skipping default index creation to avoid PK/index collisions.
|
||||
- Alembic is configured for schema migrations (`alembic/` directory).
|
||||
|
||||
## Data Ingestion Flow
|
||||
1. **Plugin** connects to `/ws/position` with a shared secret.
|
||||
2. Sends JSON frames of types:
|
||||
- `telemetry`: parsed into `TelemetrySnapshot`, upserted into `live_snapshots`, persisted to `telemetry_events`, and broadcast to browser clients.
|
||||
- `spawn`: parsed into `SpawnEvent`, inserted into `spawn_events`.
|
||||
- `rare`: increments `rare_stats` and `rare_stats_sessions` via upsert operations.
|
||||
- `chat`: broadcast to browser clients without DB writes.
|
||||
3. **Browser** connects to `/ws/live` to receive live updates and can send commands to plugins.
|
||||
|
||||
## HTTP Query Endpoints
|
||||
- **GET /live**: returns recent snapshots (last 30s) plus rare counts per character.
|
||||
- **GET /history**: returns ordered telemetry history with optional time filters.
|
||||
- **GET /trails**: returns positional trails for a lookback window.
|
||||
|
||||
This architecture enables real-time telemetry ingestion, historical time-series analysis, and an interactive front-end map for tracking players and spawn events.
|
||||
45
EVENT_FORMATS.json
Normal file
45
EVENT_FORMATS.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"_comment": "These are individual example payloads keyed by event type. Send each payload separately over the WS connection.",
|
||||
"telemetry": {
|
||||
"type": "telemetry",
|
||||
"character_name": "string",
|
||||
"session_id": "string",
|
||||
"timestamp": "2025-04-22T13:45:00Z",
|
||||
"ew": 123.4,
|
||||
"ns": 567.8,
|
||||
"z": 10.2,
|
||||
"kills": 42,
|
||||
"kills_per_hour": 7.0,
|
||||
"onlinetime": "00.05:00",
|
||||
"deaths": 1,
|
||||
"prismatic_taper_count": 17,
|
||||
"vt_state": "Combat",
|
||||
"mem_mb": 256.5,
|
||||
"cpu_pct": 12.3,
|
||||
"mem_handles": 1024
|
||||
},
|
||||
"spawn": {
|
||||
"type": "spawn",
|
||||
"timestamp": "2025-04-22T13:46:00Z",
|
||||
"character_name": "MyCharacter",
|
||||
"mob": "Forest Troll",
|
||||
"ew": 100.1,
|
||||
"ns": 200.2
|
||||
},
|
||||
"chat": {
|
||||
"type": "chat",
|
||||
"timestamp": "2025-04-22T13:47:00Z",
|
||||
"character_name": "MyCharacter",
|
||||
"text": "Hello world!",
|
||||
"color": "#88FF00"
|
||||
},
|
||||
"rare": {
|
||||
"type": "rare",
|
||||
"timestamp": "2025-04-22T13:48:00Z",
|
||||
"character_name": "MyCharacter",
|
||||
"name": "Golden Gryphon",
|
||||
"ew": 150.5,
|
||||
"ns": 350.7,
|
||||
"z": 5.0
|
||||
}
|
||||
}
|
||||
48
FIXES.md
Normal file
48
FIXES.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Planned Fixes and Enhancements
|
||||
|
||||
_This document captures the next set of improvements and fixes for Dereth Tracker._
|
||||
|
||||
## 1. Chat Window Styling and Format
|
||||
- **Terminal-style chat interface**
|
||||
- Redesign the chat window to mimic Asheron’s Call in-game chat: monospaced font, dark semi-transparent background, and text entry at the bottom.
|
||||
- Implement timestamped message prefixes (e.g., `[12:34] character: message`).
|
||||
- Support command- and system-level styling (e.g., whispers, party chat) with distinct color cues.
|
||||
|
||||
## 2. Incoming Message Parsing
|
||||
- **Strip protocol overhead**
|
||||
- Remove JSON envelope artifacts (e.g., remove quotes, braces) so only raw message text appears.
|
||||
- Validate and sanitize incoming payloads (e.g., escape HTML, truncate length).
|
||||
- Optionally support rich-text / emotes by parsing simple markup (e.g., `*bold*`, `/me action`).
|
||||
|
||||
## 3. Message Color Scheme
|
||||
- **Per-character consistent colors**
|
||||
- Map each character name to a unique, but legible, pastel or muted color.
|
||||
- Ensure sufficient contrast with the chat background (WCAG AA compliance).
|
||||
- Provide user override settings for theme (light/dark) and custom palettes.
|
||||
|
||||
## 4. Command Prompt Integration
|
||||
- **Client-side command entry**
|
||||
- Allow slash-commands in chat input (e.g., `/kick PlayerName`, `/whisper PlayerName Hello`).
|
||||
- Validate commands before sending to `/ws/live` and route to the correct plugin WebSocket.
|
||||
- Show feedback on command success/failure in the chat window.
|
||||
|
||||
## 5. Security Hardening
|
||||
- **Authentication & Authorization**
|
||||
- Enforce TLS (HTTPS/WSS) for all HTTP and WebSocket connections.
|
||||
- Protect `/ws/position` with rotating shared secrets or token-based auth (e.g., JWT).
|
||||
- Rate-limit incoming telemetry and chat messages to prevent flooding.
|
||||
- Sanitize all inputs to guard against injection (SQL, XSS) and implement strict CSP headers.
|
||||
|
||||
## 6. Performance and Scalability
|
||||
- **Throttling and Load Handling**
|
||||
- Batch updates during high-frequency telemetry bursts to reduce WebSocket churn.
|
||||
- Cache recent `/live` and `/trails` responses in-memory to relieve SQLite under load.
|
||||
- Plan for horizontal scaling: stateless FastAPI behind a load balancer with shared database or in-memory pub/sub.
|
||||
|
||||
## 7. Testing and Quality Assurance
|
||||
- **Automated Tests**
|
||||
- Unit tests for `db.save_snapshot`, HTTP endpoints, and WebSocket handlers.
|
||||
- E2E tests for the frontend UI (using Puppeteer or Playwright) to verify chat and map functionality.
|
||||
- Security regression tests for input sanitization and auth enforcement.
|
||||
|
||||
_Refer to this list when planning next development sprints. Each item should be broken down into individual tickets or pull requests._
|
||||
38
LESSONSLEARNED.md
Normal file
38
LESSONSLEARNED.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Lessons Learned
|
||||
|
||||
_This document captures the key takeaways and implementation details from today's troubleshooting session._
|
||||
|
||||
## 1. API Routing & Proxy Configuration
|
||||
- **API_BASE constant**: The frontend (`static/script.js`) uses a base path `API_BASE` (default `/api`) to prefix all HTTP and WebSocket calls. Always update this to match your proxy mount point.
|
||||
- **Nginx WebSocket forwarding**: To proxy WebSockets, you must forward the `Upgrade` and `Connection` headers:
|
||||
```nginx
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```
|
||||
Without these, the WS handshake downgrades to a normal HTTP GET, resulting in 404s.
|
||||
|
||||
## 2. Debugging WebSocket Traffic
|
||||
- Logged all incoming WS frames in `main.py`:
|
||||
- `[WS-PLUGIN RX] <client>: <raw>` for messages on `/ws/position`
|
||||
- `[WS-LIVE RX] <client>: <parsed-json>` for messages on `/ws/live`
|
||||
- These prints surface registration, telemetry, chat, and command packets, aiding root-cause analysis.
|
||||
|
||||
## 3. Data Serialization Fix
|
||||
- Python `datetime` objects are not JSON-serializable by default. We wrapped outbound payloads via FastAPI’s `jsonable_encoder` in `_broadcast_to_browser_clients` so that:
|
||||
```python
|
||||
data = jsonable_encoder(snapshot)
|
||||
await ws.send_json(data)
|
||||
```
|
||||
This ensures ISO8601 strings for timestamps and eliminates `TypeError: Object of type datetime is not JSON serializable`.
|
||||
|
||||
## 4. Frontend Adjustments
|
||||
- **Chat input positioning**: Moved the `.chat-form` to `position: absolute; bottom: 0;` so the input always sticks to the bottom of its window.
|
||||
- **Text color**: Forced the input text and placeholder to white (`.chat-input, .chat-input::placeholder { color: #fff; }`) and forcibly set all incoming messages to white via `.chat-messages div { color: #fff !important; }`.
|
||||
- **Padding for messages**: Added `padding-bottom` to `.chat-messages` to avoid new messages being hidden behind the fixed input bar.
|
||||
|
||||
## 5. General Best Practices
|
||||
- Clear browser cache after updating static assets to avoid stale JS/CSS.
|
||||
- Keep patches targeted: fix the source of issues (e.g., JSON encoding or missing headers) rather than applying superficial workarounds.
|
||||
- Use consistent CSS variables for theming (e.g., `--text`, `--bg-main`).
|
||||
|
||||
By consolidating these lessons, we can onboard faster next time and avoid repeating these pitfalls.
|
||||
59
README.md
59
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Dereth Tracker
|
||||
|
||||
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a PostgreSQL (TimescaleDB) database for efficient time-series storage, provides a live map interface, and includes a comprehensive inventory management system for tracking and searching character equipment.
|
||||
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a PostgreSQL (TimescaleDB) database for efficient time-series storage, and provides a live map interface along with a sample data generator for testing.
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
|
|
@ -16,27 +16,20 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
|
|||
|
||||
## Overview
|
||||
|
||||
This project provides:
|
||||
- This project provides:
|
||||
- A FastAPI backend with endpoints for receiving and querying telemetry data.
|
||||
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
|
||||
- A live, interactive map using static HTML, CSS, and JavaScript.
|
||||
- A comprehensive inventory management system with search capabilities.
|
||||
- Real-time inventory updates via WebSocket when characters log in/out.
|
||||
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
|
||||
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
|
||||
|
||||
## Features
|
||||
|
||||
- **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (protected by a shared secret).
|
||||
- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret).
|
||||
- **GET /live**: Fetch active players seen in the last 30 seconds.
|
||||
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
|
||||
- **GET /debug**: Health check endpoint.
|
||||
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
|
||||
- **Inventory Management**:
|
||||
- Real-time inventory updates via WebSocket on character login/logout
|
||||
- Advanced search across all character inventories
|
||||
- Filter by character, equipment type, material, stats, and more
|
||||
- Sort by any column with live results
|
||||
- Track item properties including spells, armor level, damage ratings
|
||||
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
|
||||
|
||||
## Requirements
|
||||
|
|
@ -225,13 +218,11 @@ For a complete reference of JSON payloads accepted by the backend (over `/ws/pos
|
|||
- **Spawn events** (`type`: "spawn")
|
||||
- **Chat events** (`type`: "chat")
|
||||
- **Rare events** (`type`: "rare")
|
||||
- **Inventory events** (`type`: "inventory")
|
||||
|
||||
Notes on payload changes:
|
||||
- Spawn events no longer require the `z` coordinate; if omitted, the server defaults it to 0.0.
|
||||
Coordinates (`ew`, `ns`, `z`) may be sent as JSON numbers or strings; the backend will coerce them to floats.
|
||||
- Telemetry events have removed the `latency_ms` field; please omit it from your payloads.
|
||||
- Inventory events are sent automatically on character login/logout containing complete inventory data.
|
||||
|
||||
Each entry shows all required and optional fields, their types, and example values.
|
||||
|
||||
|
|
@ -262,14 +253,11 @@ Response:
|
|||
## Frontend
|
||||
|
||||
- **Live Map**: `static/index.html` – Real-time player positions on a map.
|
||||
- **Inventory Search**: `static/inventory.html` – Search and browse character inventories with advanced filtering.
|
||||
|
||||
## Database Schema
|
||||
|
||||
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data,
|
||||
aggregate character statistics, and a separate inventory database for equipment management.
|
||||
|
||||
### Telemetry Database Tables:
|
||||
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data
|
||||
and aggregate character statistics. The primary tables are:
|
||||
|
||||
- **telemetry_events** (hypertable):
|
||||
- `id` (PK, serial)
|
||||
|
|
@ -309,41 +297,6 @@ aggregate character statistics, and a separate inventory database for equipment
|
|||
- `timestamp` (timestamptz)
|
||||
- `ew`, `ns`, `z` (float)
|
||||
|
||||
### Inventory Database Tables:
|
||||
|
||||
- **items**:
|
||||
- `id` (PK, serial)
|
||||
- `character_name` (text, indexed)
|
||||
- `item_id` (bigint)
|
||||
- `name` (text)
|
||||
- `object_class` (integer)
|
||||
- `icon`, `value`, `burden` (integer)
|
||||
- `current_wielded_location`, `bonded`, `attuned`, `unique` (various)
|
||||
- `timestamp` (timestamptz)
|
||||
|
||||
- **item_combat_stats**:
|
||||
- `item_id` (FK to items.id)
|
||||
- `armor_level`, `max_damage` (integer)
|
||||
- `damage_bonus`, `attack_bonus` (float)
|
||||
- Various defense bonuses
|
||||
|
||||
- **item_enhancements**:
|
||||
- `item_id` (FK to items.id)
|
||||
- `material` (varchar)
|
||||
- `item_set` (varchar)
|
||||
- `tinks`, `workmanship` (integer/float)
|
||||
|
||||
- **item_spells**:
|
||||
- `item_id` (FK to items.id)
|
||||
- `spell_id` (integer)
|
||||
- `spell_name` (text)
|
||||
- `is_legendary`, `is_epic` (boolean)
|
||||
|
||||
- **item_raw_data**:
|
||||
- `item_id` (FK to items.id)
|
||||
- `int_values`, `double_values`, `string_values`, `bool_values` (JSONB)
|
||||
- `original_json` (JSONB)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Feel free to open issues or submit pull requests.
|
||||
|
|
|
|||
62
TODO.md
Normal file
62
TODO.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
## TODO: Migration & Parity Plan
|
||||
|
||||
### Detailed Plan
|
||||
1. [ ] Review Repository for Data Storage and Event Handling
|
||||
- [ ] Scan for SQLite usage (telemetry, spawns, chat, session data)
|
||||
- [ ] Identify all event ingestion code paths (WebSocket, HTTP, direct DB inserts)
|
||||
- [ ] Locate old or deprecated payload handling
|
||||
2. [ ] Update Database Access Layer to PostgreSQL/TimescaleDB
|
||||
- [ ] Replace SQLite code with SQLAlchemy models & Alembic migrations
|
||||
- [ ] Configure TimescaleDB hypertable for telemetry data
|
||||
- [ ] Create migration for spawn events table
|
||||
- [ ] Set up `DATABASE_URL` and (optional) local Docker Compose service
|
||||
3. [ ] Refactor Event Ingestion Endpoints and Logic
|
||||
- [ ] Modify `/ws/position` to accept new schemas (telemetry, spawn, chat)
|
||||
- [ ] Persist telemetry and spawn events to PostgreSQL
|
||||
- [ ] Continue broadcasting chat messages without persisting
|
||||
4. [ ] Update Data Models and API Response Types
|
||||
- [ ] Align Pydantic schemas to new event payload structures
|
||||
- [ ] Update `/live`, `/history`, `/trails` to query Postgres
|
||||
- [ ] Optionally add `GET /spawns` endpoint for spawn data
|
||||
5. [ ] Migrate or Clean Historical Data
|
||||
- [ ] If needed, write script to migrate existing SQLite data to Postgres
|
||||
- [ ] Otherwise remove old migration and data transformation code
|
||||
6. [ ] Refactor Frontend to Query and Visualize New Data (deferred)
|
||||
7. [ ] Add or Update Grafana Dashboards (deferred)
|
||||
8. [ ] Testing & Verification (deferred)
|
||||
9. [ ] Documentation & Developer Instructions
|
||||
- [ ] Update README and docs for PostgreSQL/TimescaleDB setup
|
||||
10. [ ] Maintenance and Future Enhancements
|
||||
- [ ] Document data retention and aggregation policies for TimescaleDB
|
||||
|
||||
### Phases
|
||||
|
||||
### Phase 1: Core Migration & Parity
|
||||
- [ ] Remove SQLite usage and associated code (`db.py` and direct `sqlite3` calls).
|
||||
- [ ] Integrate PostgreSQL/TimescaleDB using SQLAlchemy and Alembic for migrations.
|
||||
- Set up `DATABASE_URL` environment variable for connection.
|
||||
- (Optional) Add a TimescaleDB service in `docker-compose.yml` for local development.
|
||||
- [ ] Define SQLAlchemy models and create initial Alembic migration:
|
||||
- Telemetry table as a TimescaleDB hypertable.
|
||||
- Spawn events table.
|
||||
- [ ] Update backend (`main.py`):
|
||||
- Ingest `telemetry` and new `spawn` messages from `/ws/position` WebSocket.
|
||||
- Persist telemetry and spawn events to PostgreSQL.
|
||||
- Continue broadcasting `chat` messages without persisting.
|
||||
- [ ] Ensure existing endpoints (`/live`, `/history`, `/trails`) operate against the new database.
|
||||
- [ ] (Optional) Add retrieval endpoint for spawn events (e.g., `GET /spawns`).
|
||||
|
||||
### Phase 2: Frontend & Visualization
|
||||
- [ ] Update frontend to display spawn events (markers or lists).
|
||||
- [ ] Expose new telemetry metrics in the UI: `latency_ms`, `mem_mb`, `cpu_pct`, `mem_handles`.
|
||||
|
||||
### Phase 3: Dashboards & Monitoring
|
||||
* [ ] Provision or update Grafana dashboards for:
|
||||
- Telemetry performance (TimescaleDB queries, hypertable metrics).
|
||||
- Spawn event heatmaps and trends.
|
||||
- Rare event heatmaps and trends.
|
||||
|
||||
### Phase 4: Documentation & Maintenance
|
||||
- [ ] Finalize README and developer docs with PostgreSQL setup, migration steps, and usage examples.
|
||||
- [ ] Document how to add new event types or payload fields, including schema, migrations, and tests.
|
||||
- [ ] Establish data retention and aggregation policies for TimescaleDB hypertables.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,4 +7,3 @@ pydantic==2.5.0
|
|||
python-multipart==0.0.6
|
||||
python-json-logger==2.0.7
|
||||
psycopg2-binary==2.9.9
|
||||
sse-starlette==1.8.2
|
||||
439
main.py
439
main.py
|
|
@ -10,7 +10,6 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Dict, List, Any
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -61,249 +60,6 @@ _cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None}
|
|||
_cache_task: asyncio.Task | None = None
|
||||
_rares_cache_task: asyncio.Task | None = None
|
||||
|
||||
# Player tracking for debug purposes
|
||||
_player_history: list = [] # List of player sets from last 10 refreshes
|
||||
_player_events: list = [] # List of player enter/exit events
|
||||
_max_history_size = 10 # Keep last 10 player sets
|
||||
_max_events_size = 100 # Keep last 100 events
|
||||
|
||||
# Telemetry timing tracking for debug purposes
|
||||
_player_telemetry_times: dict = {} # character_name -> list of timestamps
|
||||
_max_telemetry_history = 20 # Keep last 20 telemetry timestamps per player
|
||||
|
||||
# Simple WebSocket connection counters (Phase 1)
|
||||
_plugin_connections = 0
|
||||
_browser_connections = 0
|
||||
|
||||
# Simple database query performance counters (Phase 2)
|
||||
_total_queries = 0
|
||||
_total_query_time = 0.0
|
||||
|
||||
# Simple recent activity tracking (Phase 3)
|
||||
_recent_telemetry_messages = []
|
||||
_max_recent_messages = 50
|
||||
|
||||
def _track_player_changes(new_players: list) -> None:
|
||||
"""Track player changes for debugging flapping issues."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Get current player names
|
||||
current_players = {p["character_name"] for p in new_players}
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
# Track telemetry timing for each player
|
||||
for player_data in new_players:
|
||||
player_name = player_data["character_name"]
|
||||
player_timestamp = player_data.get("timestamp")
|
||||
|
||||
# Convert timestamp if it's a string
|
||||
if isinstance(player_timestamp, str):
|
||||
try:
|
||||
player_timestamp = datetime.fromisoformat(player_timestamp.replace('Z', '+00:00'))
|
||||
except:
|
||||
player_timestamp = timestamp
|
||||
elif player_timestamp is None:
|
||||
player_timestamp = timestamp
|
||||
|
||||
# Initialize player telemetry tracking if needed
|
||||
if player_name not in _player_telemetry_times:
|
||||
_player_telemetry_times[player_name] = []
|
||||
|
||||
# Add this telemetry timestamp
|
||||
_player_telemetry_times[player_name].append(player_timestamp)
|
||||
|
||||
# Trim to max history
|
||||
if len(_player_telemetry_times[player_name]) > _max_telemetry_history:
|
||||
_player_telemetry_times[player_name].pop(0)
|
||||
|
||||
# Get previous player names if we have history
|
||||
previous_players = set()
|
||||
if _player_history:
|
||||
previous_players = {p["character_name"] for p in _player_history[-1]["players"]}
|
||||
|
||||
# Find players who entered and exited
|
||||
entered_players = current_players - previous_players
|
||||
exited_players = previous_players - current_players
|
||||
|
||||
# Log events with telemetry timing analysis
|
||||
for player in entered_players:
|
||||
# Check if this is due to timing gap
|
||||
timing_gap = None
|
||||
if player in _player_telemetry_times and len(_player_telemetry_times[player]) >= 2:
|
||||
last_two = _player_telemetry_times[player][-2:]
|
||||
timing_gap = (last_two[1] - last_two[0]).total_seconds()
|
||||
|
||||
event = {
|
||||
"timestamp": timestamp,
|
||||
"type": "enter",
|
||||
"character_name": player,
|
||||
"total_players": len(current_players),
|
||||
"timing_gap": timing_gap
|
||||
}
|
||||
_player_events.append(event)
|
||||
gap_info = f" (gap: {timing_gap:.1f}s)" if timing_gap and timing_gap > 25 else ""
|
||||
logger.debug(f"Player entered: {player} (total: {len(current_players)}){gap_info}")
|
||||
|
||||
for player in exited_players:
|
||||
# Calculate time since last telemetry
|
||||
last_telemetry_age = None
|
||||
if player in _player_telemetry_times and _player_telemetry_times[player]:
|
||||
last_telemetry = _player_telemetry_times[player][-1]
|
||||
last_telemetry_age = (timestamp - last_telemetry).total_seconds()
|
||||
|
||||
event = {
|
||||
"timestamp": timestamp,
|
||||
"type": "exit",
|
||||
"character_name": player,
|
||||
"total_players": len(current_players),
|
||||
"last_telemetry_age": last_telemetry_age
|
||||
}
|
||||
_player_events.append(event)
|
||||
age_info = f" (last telemetry: {last_telemetry_age:.1f}s ago)" if last_telemetry_age else ""
|
||||
logger.debug(f"Player exited: {player} (total: {len(current_players)}){age_info}")
|
||||
|
||||
# Add current state to history
|
||||
history_entry = {
|
||||
"timestamp": timestamp,
|
||||
"players": new_players,
|
||||
"player_count": len(new_players),
|
||||
"player_names": list(current_players)
|
||||
}
|
||||
_player_history.append(history_entry)
|
||||
|
||||
# Trim history to max size
|
||||
if len(_player_history) > _max_history_size:
|
||||
_player_history.pop(0)
|
||||
|
||||
# Trim events to max size
|
||||
if len(_player_events) > _max_events_size:
|
||||
_player_events.pop(0)
|
||||
|
||||
def _analyze_flapping_patterns() -> dict:
|
||||
"""Analyze player events to identify flapping patterns."""
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
if not _player_events:
|
||||
return {"flapping_players": [], "frequent_events": [], "analysis": "No events to analyze"}
|
||||
|
||||
# Count events per player
|
||||
player_event_counts = Counter()
|
||||
player_flap_counts = defaultdict(int)
|
||||
|
||||
# Track recent activity per player (last 10 events)
|
||||
recent_player_activity = defaultdict(list)
|
||||
|
||||
for event in _player_events[-50:]: # Analyze last 50 events
|
||||
player = event["character_name"]
|
||||
event_type = event["type"]
|
||||
player_event_counts[player] += 1
|
||||
recent_player_activity[player].append(event_type)
|
||||
|
||||
# Identify flapping players (players with many enter/exit cycles)
|
||||
flapping_players = []
|
||||
for player, activity in recent_player_activity.items():
|
||||
if len(activity) >= 4: # At least 4 events
|
||||
# Count alternating enter/exit patterns
|
||||
flap_score = 0
|
||||
for i in range(1, len(activity)):
|
||||
if activity[i] != activity[i-1]: # Different from previous
|
||||
flap_score += 1
|
||||
|
||||
if flap_score >= 3: # At least 3 transitions
|
||||
flapping_players.append({
|
||||
"character_name": player,
|
||||
"events": len(activity),
|
||||
"flap_score": flap_score,
|
||||
"recent_activity": activity[-10:] # Last 10 events
|
||||
})
|
||||
|
||||
# Sort by flap score
|
||||
flapping_players.sort(key=lambda x: x["flap_score"], reverse=True)
|
||||
|
||||
# Most active players
|
||||
frequent_events = [
|
||||
{"character_name": player, "event_count": count}
|
||||
for player, count in player_event_counts.most_common(10)
|
||||
]
|
||||
|
||||
# Recent activity summary
|
||||
recent_enters = sum(1 for e in _player_events[-20:] if e["type"] == "enter")
|
||||
recent_exits = sum(1 for e in _player_events[-20:] if e["type"] == "exit")
|
||||
|
||||
return {
|
||||
"flapping_players": flapping_players,
|
||||
"frequent_events": frequent_events,
|
||||
"recent_activity": {
|
||||
"enters": recent_enters,
|
||||
"exits": recent_exits,
|
||||
"net_change": recent_enters - recent_exits
|
||||
},
|
||||
"analysis": f"Found {len(flapping_players)} potentially flapping players"
|
||||
}
|
||||
|
||||
def _analyze_telemetry_timing() -> dict:
|
||||
"""Analyze telemetry timing patterns for all players."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
timing_analysis = {}
|
||||
problem_players = []
|
||||
|
||||
for player_name, timestamps in _player_telemetry_times.items():
|
||||
if len(timestamps) < 2:
|
||||
continue
|
||||
|
||||
# Calculate intervals between telemetry messages
|
||||
intervals = []
|
||||
for i in range(1, len(timestamps)):
|
||||
interval = (timestamps[i] - timestamps[i-1]).total_seconds()
|
||||
intervals.append(interval)
|
||||
|
||||
if not intervals:
|
||||
continue
|
||||
|
||||
# Calculate timing statistics
|
||||
avg_interval = sum(intervals) / len(intervals)
|
||||
min_interval = min(intervals)
|
||||
max_interval = max(intervals)
|
||||
|
||||
# Count problematic intervals (>30s)
|
||||
long_gaps = [i for i in intervals if i > 30]
|
||||
recent_long_gaps = [i for i in intervals[-5:] if i > 30] # Last 5 intervals
|
||||
|
||||
# Determine if this player has timing issues
|
||||
has_timing_issues = len(long_gaps) > 0 or max_interval > 35
|
||||
|
||||
timing_stats = {
|
||||
"character_name": player_name,
|
||||
"total_messages": len(timestamps),
|
||||
"avg_interval": round(avg_interval, 1),
|
||||
"min_interval": round(min_interval, 1),
|
||||
"max_interval": round(max_interval, 1),
|
||||
"long_gaps_count": len(long_gaps),
|
||||
"recent_long_gaps": len(recent_long_gaps),
|
||||
"last_message_age": (datetime.now(timezone.utc) - timestamps[-1]).total_seconds() if timestamps else 0,
|
||||
"has_timing_issues": has_timing_issues,
|
||||
"recent_intervals": [round(i, 1) for i in intervals[-5:]] # Last 5 intervals
|
||||
}
|
||||
|
||||
timing_analysis[player_name] = timing_stats
|
||||
|
||||
if has_timing_issues:
|
||||
problem_players.append(timing_stats)
|
||||
|
||||
# Sort problem players by severity (max interval)
|
||||
problem_players.sort(key=lambda x: x["max_interval"], reverse=True)
|
||||
|
||||
return {
|
||||
"all_players": timing_analysis,
|
||||
"problem_players": problem_players,
|
||||
"summary": {
|
||||
"total_tracked_players": len(timing_analysis),
|
||||
"players_with_issues": len(problem_players),
|
||||
"avg_intervals": [stats["avg_interval"] for stats in timing_analysis.values()],
|
||||
}
|
||||
}
|
||||
|
||||
async def _refresh_cache_loop() -> None:
|
||||
"""Background task: refresh `/live` and `/trails` caches every 5 seconds."""
|
||||
consecutive_failures = 0
|
||||
|
|
@ -336,12 +92,7 @@ async def _refresh_cache_loop() -> None:
|
|||
# Use a single connection for both queries to reduce connection churn
|
||||
async with database.connection() as conn:
|
||||
rows = await conn.fetch_all(sql_live, {"cutoff": cutoff})
|
||||
new_players = [dict(r) for r in rows]
|
||||
|
||||
# Track player changes for debugging
|
||||
_track_player_changes(new_players)
|
||||
|
||||
_cached_live["players"] = new_players
|
||||
_cached_live["players"] = [dict(r) for r in rows]
|
||||
|
||||
# Recompute trails (last 600s)
|
||||
cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600)
|
||||
|
|
@ -596,104 +347,6 @@ async def on_shutdown():
|
|||
def debug():
|
||||
return {"status": "OK"}
|
||||
|
||||
@app.get("/debug/player-flapping")
|
||||
async def get_player_flapping_debug():
|
||||
"""Return player tracking data for debugging flapping issues."""
|
||||
try:
|
||||
# Analyze flapping patterns
|
||||
flapping_analysis = _analyze_flapping_patterns()
|
||||
|
||||
# Analyze telemetry timing
|
||||
timing_analysis = _analyze_telemetry_timing()
|
||||
|
||||
# Get recent events (last 50)
|
||||
recent_events = _player_events[-50:] if len(_player_events) > 50 else _player_events
|
||||
|
||||
# Convert timestamps to ISO format for JSON serialization
|
||||
formatted_events = []
|
||||
for event in recent_events:
|
||||
formatted_event = event.copy()
|
||||
formatted_event["timestamp"] = event["timestamp"].isoformat()
|
||||
formatted_events.append(formatted_event)
|
||||
|
||||
# Format history
|
||||
formatted_history = []
|
||||
for entry in _player_history:
|
||||
formatted_entry = {
|
||||
"timestamp": entry["timestamp"].isoformat(),
|
||||
"player_count": entry["player_count"],
|
||||
"player_names": entry["player_names"]
|
||||
}
|
||||
formatted_history.append(formatted_entry)
|
||||
|
||||
# Format timing data for JSON serialization
|
||||
formatted_timing = {}
|
||||
for player_name, timing_data in timing_analysis["all_players"].items():
|
||||
formatted_timing[player_name] = timing_data.copy()
|
||||
# Round last_message_age for readability
|
||||
formatted_timing[player_name]["last_message_age"] = round(timing_data["last_message_age"], 1)
|
||||
|
||||
return {
|
||||
"current_players": len(_cached_live.get("players", [])),
|
||||
"history": formatted_history,
|
||||
"recent_events": formatted_events,
|
||||
"flapping_analysis": flapping_analysis,
|
||||
"timing_analysis": {
|
||||
"all_players": formatted_timing,
|
||||
"problem_players": timing_analysis["problem_players"],
|
||||
"summary": timing_analysis["summary"]
|
||||
},
|
||||
"tracking_stats": {
|
||||
"history_entries": len(_player_history),
|
||||
"total_events": len(_player_events),
|
||||
"tracked_players": len(_player_telemetry_times),
|
||||
"max_history_size": _max_history_size,
|
||||
"max_events_size": _max_events_size
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get player flapping debug data: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@app.get("/debug/websocket-health")
|
||||
async def get_websocket_health():
|
||||
"""Return simple WebSocket connection counts."""
|
||||
try:
|
||||
return {
|
||||
"plugin_connections": _plugin_connections,
|
||||
"browser_connections": _browser_connections,
|
||||
"total_connections": _plugin_connections + _browser_connections
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get WebSocket health data: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@app.get("/debug/database-performance")
|
||||
async def get_database_performance():
|
||||
"""Return simple database query performance statistics."""
|
||||
try:
|
||||
avg_query_time = (_total_query_time / _total_queries) if _total_queries > 0 else 0.0
|
||||
return {
|
||||
"total_queries": _total_queries,
|
||||
"total_query_time": round(_total_query_time, 3),
|
||||
"average_query_time": round(avg_query_time, 3)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get database performance data: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@app.get("/debug/recent-activity")
|
||||
async def get_recent_activity():
|
||||
"""Return recent telemetry activity feed."""
|
||||
try:
|
||||
return {
|
||||
"recent_messages": _recent_telemetry_messages.copy(),
|
||||
"total_messages": len(_recent_telemetry_messages),
|
||||
"max_messages": _max_recent_messages
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recent activity data: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@app.get("/live", response_model=dict)
|
||||
@app.get("/live/", response_model=dict)
|
||||
|
|
@ -1246,8 +899,6 @@ async def ws_receive_snapshots(
|
|||
- rare: update total and session rare counts, persist event
|
||||
- chat: broadcast chat messages to browsers
|
||||
"""
|
||||
global _plugin_connections
|
||||
|
||||
# Authenticate plugin connection using shared secret
|
||||
key = secret or x_plugin_secret
|
||||
if key != SHARED_SECRET:
|
||||
|
|
@ -1257,11 +908,7 @@ async def ws_receive_snapshots(
|
|||
return
|
||||
# Accept the WebSocket connection
|
||||
await websocket.accept()
|
||||
logger.info(f"🔌 PLUGIN_CONNECTED: {websocket.client}")
|
||||
|
||||
# Track plugin connection
|
||||
_plugin_connections += 1
|
||||
|
||||
logger.info(f"Plugin WebSocket connected: {websocket.client}")
|
||||
try:
|
||||
while True:
|
||||
# Read next text frame
|
||||
|
|
@ -1270,7 +917,7 @@ async def ws_receive_snapshots(
|
|||
# Debug: log all incoming plugin WebSocket messages
|
||||
logger.debug(f"Plugin WebSocket RX from {websocket.client}: {raw}")
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"🔌 PLUGIN_DISCONNECTED: {websocket.client}")
|
||||
logger.info(f"Plugin WebSocket disconnected: {websocket.client}")
|
||||
break
|
||||
# Parse JSON payload
|
||||
try:
|
||||
|
|
@ -1284,7 +931,7 @@ async def ws_receive_snapshots(
|
|||
name = data.get("character_name") or data.get("player_name")
|
||||
if isinstance(name, str):
|
||||
plugin_conns[name] = websocket
|
||||
logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}")
|
||||
logger.info(f"Registered plugin connection for character: {name}")
|
||||
continue
|
||||
# --- Spawn event: persist to spawn_events table ---
|
||||
if msg_type == "spawn":
|
||||
|
|
@ -1305,12 +952,6 @@ async def ws_receive_snapshots(
|
|||
# Parse telemetry snapshot and update in-memory state
|
||||
payload = data.copy()
|
||||
payload.pop("type", None)
|
||||
character_name = payload.get('character_name', 'unknown')
|
||||
|
||||
# Track message receipt and start timing
|
||||
telemetry_start_time = time.time()
|
||||
logger.info(f"📨 TELEMETRY_RECEIVED: {character_name} from {websocket.client}")
|
||||
|
||||
try:
|
||||
snap = TelemetrySnapshot.parse_obj(payload)
|
||||
live_snapshots[snap.character_name] = snap.dict()
|
||||
|
|
@ -1333,16 +974,6 @@ async def ws_receive_snapshots(
|
|||
|
||||
delta = snap.kills - last
|
||||
# Persist snapshot and any kill delta in a single transaction
|
||||
db_start_time = time.time()
|
||||
|
||||
# Log connection pool status before database operation
|
||||
try:
|
||||
pool_status = f"pool_size:{database._pool._queue.qsize()}" if hasattr(database, '_pool') and hasattr(database._pool, '_queue') else "pool_status:unknown"
|
||||
except:
|
||||
pool_status = "pool_status:error"
|
||||
|
||||
logger.info(f"💾 TELEMETRY_DB_WRITE_ATTEMPT: {snap.character_name} session:{snap.session_id[:8]} kills:{snap.kills} delta:{delta} {pool_status}")
|
||||
|
||||
try:
|
||||
async with database.transaction():
|
||||
await database.execute(
|
||||
|
|
@ -1358,60 +989,15 @@ async def ws_receive_snapshots(
|
|||
)
|
||||
await database.execute(stmt)
|
||||
logger.debug(f"Updated kills for {snap.character_name}: +{delta} (total from {last} to {snap.kills})")
|
||||
|
||||
# Success: log timing and update cache
|
||||
db_duration = (time.time() - db_start_time) * 1000
|
||||
ws_receive_snapshots._last_kills[key] = snap.kills
|
||||
|
||||
# Track database performance (Phase 2)
|
||||
global _total_queries, _total_query_time
|
||||
_total_queries += 1
|
||||
_total_query_time += db_duration / 1000.0 # Convert ms to seconds
|
||||
|
||||
# Track recent activity (Phase 3)
|
||||
global _recent_telemetry_messages, _max_recent_messages
|
||||
activity_entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"character_name": snap.character_name,
|
||||
"kills": snap.kills,
|
||||
"kill_delta": delta,
|
||||
"query_time": round(db_duration, 1)
|
||||
}
|
||||
_recent_telemetry_messages.append(activity_entry)
|
||||
if len(_recent_telemetry_messages) > _max_recent_messages:
|
||||
_recent_telemetry_messages.pop(0)
|
||||
|
||||
|
||||
# Log final pool status after successful operation
|
||||
try:
|
||||
final_pool_status = f"pool_size:{database._pool._queue.qsize()}" if hasattr(database, '_pool') and hasattr(database._pool, '_queue') else "pool_status:unknown"
|
||||
except:
|
||||
final_pool_status = "pool_status:error"
|
||||
|
||||
logger.info(f"✅ TELEMETRY_DB_WRITE_SUCCESS: {snap.character_name} took {db_duration:.1f}ms {final_pool_status}")
|
||||
|
||||
except Exception as db_error:
|
||||
db_duration = (time.time() - db_start_time) * 1000
|
||||
|
||||
|
||||
# Log pool status during failure
|
||||
try:
|
||||
error_pool_status = f"pool_size:{database._pool._queue.qsize()}" if hasattr(database, '_pool') and hasattr(database._pool, '_queue') else "pool_status:unknown"
|
||||
except:
|
||||
error_pool_status = "pool_status:error"
|
||||
|
||||
logger.error(f"❌ TELEMETRY_DB_WRITE_FAILED: {snap.character_name} session:{snap.session_id[:8]} took {db_duration:.1f}ms {error_pool_status} error:{db_error}", exc_info=True)
|
||||
logger.error(f"💾 Database transaction failed for {snap.character_name} (session: {snap.session_id[:8]}): {db_error}", exc_info=True)
|
||||
continue
|
||||
# Broadcast updated snapshot to all browser clients
|
||||
await _broadcast_to_browser_clients(snap.dict())
|
||||
|
||||
# Log successful processing completion with timing
|
||||
total_duration = (time.time() - telemetry_start_time) * 1000
|
||||
logger.info(f"⏱️ TELEMETRY_PROCESSING_COMPLETE: {snap.character_name} took {total_duration:.1f}ms total")
|
||||
|
||||
logger.debug(f"✅ Processed telemetry from {snap.character_name} (session: {snap.session_id[:8]}, kills: {snap.kills})")
|
||||
except Exception as e:
|
||||
total_duration = (time.time() - telemetry_start_time) * 1000
|
||||
logger.error(f"❌ TELEMETRY_PROCESSING_FAILED: {character_name} took {total_duration:.1f}ms error:{e}", exc_info=True)
|
||||
logger.error(f"❌ Failed to process telemetry event from {data.get('character_name', 'unknown')}: {e}", exc_info=True)
|
||||
continue
|
||||
# --- Rare event: update total and session counters and persist ---
|
||||
if msg_type == "rare":
|
||||
|
|
@ -1496,9 +1082,6 @@ async def ws_receive_snapshots(
|
|||
if msg_type:
|
||||
logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}")
|
||||
finally:
|
||||
# Track plugin disconnection
|
||||
_plugin_connections = max(0, _plugin_connections - 1)
|
||||
|
||||
# Clean up any plugin registrations for this socket
|
||||
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
||||
for n in to_remove:
|
||||
|
|
@ -1570,15 +1153,10 @@ async def ws_live_updates(websocket: WebSocket):
|
|||
Manages a set of connected browser clients; listens for incoming command messages
|
||||
and forwards them to the appropriate plugin client WebSocket.
|
||||
"""
|
||||
global _browser_connections
|
||||
# Add new browser client to the set
|
||||
await websocket.accept()
|
||||
browser_conns.add(websocket)
|
||||
logger.info(f"Browser WebSocket connected: {websocket.client}")
|
||||
|
||||
# Track browser connection
|
||||
_browser_connections += 1
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Receive command messages from browser
|
||||
|
|
@ -1620,9 +1198,6 @@ async def ws_live_updates(websocket: WebSocket):
|
|||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
# Track browser disconnection
|
||||
_browser_connections = max(0, _browser_connections - 1)
|
||||
|
||||
browser_conns.discard(websocket)
|
||||
logger.debug(f"Removed browser WebSocket from connection pool: {websocket.client}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,654 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Player Debug - Dereth Tracker</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
margin: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #88f;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #88f;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.event-enter {
|
||||
color: #4f4;
|
||||
}
|
||||
|
||||
.event-exit {
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.flapping-player {
|
||||
background: #332;
|
||||
border: 1px solid #664;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.flapping-player .name {
|
||||
font-weight: bold;
|
||||
color: #ff6;
|
||||
}
|
||||
|
||||
.flapping-player .score {
|
||||
color: #f88;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.history-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.player-count {
|
||||
font-weight: bold;
|
||||
color: #4f4;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #222;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #88f;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.timing-player {
|
||||
background: #223;
|
||||
border: 1px solid #446;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timing-player .name {
|
||||
font-weight: bold;
|
||||
color: #ff8;
|
||||
}
|
||||
|
||||
.timing-player .issue {
|
||||
color: #f88;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.timing-player .stats {
|
||||
color: #aaa;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 5px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timing-good {
|
||||
border-color: #464 !important;
|
||||
background: #232 !important;
|
||||
}
|
||||
|
||||
.timing-warning {
|
||||
border-color: #664 !important;
|
||||
background: #332 !important;
|
||||
}
|
||||
|
||||
.timing-critical {
|
||||
border-color: #644 !important;
|
||||
background: #322 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔍 Player Debug Dashboard</h1>
|
||||
<p>Real-time tracking of player list changes to debug flapping issues</p>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">
|
||||
<div class="loading">Loading player debug data...</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="statsGrid" style="display: none;">
|
||||
<div class="stat-card">
|
||||
<h3>Current Players</h3>
|
||||
<div class="stat-value" id="currentPlayers">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Total Events</h3>
|
||||
<div class="stat-value" id="totalEvents">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Flapping Players</h3>
|
||||
<div class="stat-value" id="flappingCount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Recent Activity</h3>
|
||||
<div class="stat-value" id="recentActivity">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Timing Issues</h3>
|
||||
<div class="stat-value" id="timingIssues">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Tracked Players</h3>
|
||||
<div class="stat-value" id="trackedPlayers">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>WebSocket Connections</h3>
|
||||
<div class="stat-value" id="websocketConnections">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Database Queries</h3>
|
||||
<div class="stat-value" id="databaseQueries">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Recent Activity</h3>
|
||||
<div class="stat-value" id="recentActivityCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sections" id="sections" style="display: none;">
|
||||
<div class="section">
|
||||
<h2>🔄 Flapping Players</h2>
|
||||
<div id="flappingPlayers">
|
||||
<p style="color: #888; font-style: italic;">No flapping detected</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>⏰ Timing Issues</h2>
|
||||
<div id="timingProblems">
|
||||
<p style="color: #888; font-style: italic;">Loading timing analysis...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📊 Player History</h2>
|
||||
<div id="playerHistory" class="event-log">
|
||||
<p style="color: #888; font-style: italic;">Loading history...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📝 Recent Events</h2>
|
||||
<div id="recentEvents" class="event-log">
|
||||
<p style="color: #888; font-style: italic;">Loading events...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🎯 Most Active Players</h2>
|
||||
<div id="frequentEvents">
|
||||
<p style="color: #888; font-style: italic;">Loading activity...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 Telemetry Timing</h2>
|
||||
<div id="telemetryTiming">
|
||||
<p style="color: #888; font-style: italic;">Loading telemetry data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🔌 WebSocket Health</h2>
|
||||
<div id="websocketHealth">
|
||||
<p style="color: #888; font-style: italic;">Loading WebSocket data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🗄️ Database Performance</h2>
|
||||
<div id="databasePerformance">
|
||||
<p style="color: #888; font-style: italic;">Loading database data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>⚡ Recent Activity</h2>
|
||||
<div id="recentActivityFeed" class="event-log">
|
||||
<p style="color: #888; font-style: italic;">Loading recent activity...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="last-updated" id="lastUpdated"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let pollInterval;
|
||||
let isPolling = false;
|
||||
|
||||
function formatTimestamp(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async function fetchDebugData() {
|
||||
try {
|
||||
// Fetch all four endpoints
|
||||
const [flappingResponse, websocketResponse, databaseResponse, activityResponse] = await Promise.all([
|
||||
fetch('/debug/player-flapping'),
|
||||
fetch('/debug/websocket-health'),
|
||||
fetch('/debug/database-performance'),
|
||||
fetch('/debug/recent-activity')
|
||||
]);
|
||||
|
||||
if (!flappingResponse.ok) {
|
||||
throw new Error(`HTTP ${flappingResponse.status}: ${flappingResponse.statusText}`);
|
||||
}
|
||||
|
||||
const data = await flappingResponse.json();
|
||||
const websocketData = websocketResponse.ok ? await websocketResponse.json() : null;
|
||||
const databaseData = databaseResponse.ok ? await databaseResponse.json() : null;
|
||||
const activityData = activityResponse.ok ? await activityResponse.json() : null;
|
||||
|
||||
// Combine the data
|
||||
const enhancedData = {
|
||||
...data,
|
||||
websocket_health: websocketData,
|
||||
database_performance: databaseData,
|
||||
recent_activity: activityData
|
||||
};
|
||||
|
||||
updateUI(enhancedData);
|
||||
showSuccess();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch debug data:', error);
|
||||
showError(`Failed to fetch data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI(data) {
|
||||
// Update stats
|
||||
document.getElementById('currentPlayers').textContent = data.current_players;
|
||||
document.getElementById('totalEvents').textContent = data.tracking_stats.total_events;
|
||||
document.getElementById('flappingCount').textContent = data.flapping_analysis.flapping_players.length;
|
||||
|
||||
const recentActivity = data.flapping_analysis.recent_activity;
|
||||
document.getElementById('recentActivity').textContent =
|
||||
`+${recentActivity.enters} -${recentActivity.exits} (${recentActivity.net_change > 0 ? '+' : ''}${recentActivity.net_change})`;
|
||||
|
||||
// Update flapping players
|
||||
const flappingDiv = document.getElementById('flappingPlayers');
|
||||
if (data.flapping_analysis.flapping_players.length === 0) {
|
||||
flappingDiv.innerHTML = '<p style="color: #4f4; font-style: italic;">✅ No flapping detected</p>';
|
||||
} else {
|
||||
flappingDiv.innerHTML = data.flapping_analysis.flapping_players.map(player => `
|
||||
<div class="flapping-player">
|
||||
<div class="name">${player.character_name}</div>
|
||||
<div class="score">Flap Score: ${player.flap_score} | Events: ${player.events}</div>
|
||||
<div style="font-size: 0.8rem; color: #aaa; margin-top: 5px;">
|
||||
Recent: ${player.recent_activity.join(' → ')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update history
|
||||
const historyDiv = document.getElementById('playerHistory');
|
||||
if (data.history.length === 0) {
|
||||
historyDiv.innerHTML = '<p style="color: #888; font-style: italic;">No history available</p>';
|
||||
} else {
|
||||
historyDiv.innerHTML = data.history.slice().reverse().map(entry => `
|
||||
<div class="history-entry">
|
||||
<span class="timestamp">${formatTimestamp(entry.timestamp)}</span>
|
||||
<span class="player-count">${entry.player_count} players</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update recent events
|
||||
const eventsDiv = document.getElementById('recentEvents');
|
||||
if (data.recent_events.length === 0) {
|
||||
eventsDiv.innerHTML = '<p style="color: #888; font-style: italic;">No recent events</p>';
|
||||
} else {
|
||||
eventsDiv.innerHTML = data.recent_events.slice().reverse().map(event => `
|
||||
<div class="event-${event.type}">
|
||||
${formatTimestamp(event.timestamp)} - ${event.character_name} ${event.type === 'enter' ? 'joined' : 'left'} (${event.total_players} total)
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update frequent events
|
||||
const frequentDiv = document.getElementById('frequentEvents');
|
||||
if (data.flapping_analysis.frequent_events.length === 0) {
|
||||
frequentDiv.innerHTML = '<p style="color: #888; font-style: italic;">No activity data</p>';
|
||||
} else {
|
||||
frequentDiv.innerHTML = data.flapping_analysis.frequent_events.map(player => `
|
||||
<div style="display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #333;">
|
||||
<span>${player.character_name}</span>
|
||||
<span style="color: #88f;">${player.event_count} events</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update timing issues
|
||||
const timingDiv = document.getElementById('timingProblems');
|
||||
const timingData = data.timing_analysis || { problem_players: [], summary: { total_tracked_players: 0 } };
|
||||
document.getElementById('timingIssues').textContent = timingData.problem_players.length;
|
||||
document.getElementById('trackedPlayers').textContent = timingData.summary.total_tracked_players;
|
||||
|
||||
if (timingData.problem_players.length === 0) {
|
||||
timingDiv.innerHTML = '<p style="color: #4f4; font-style: italic;">✅ No timing issues detected</p>';
|
||||
} else {
|
||||
timingDiv.innerHTML = timingData.problem_players.map(player => {
|
||||
let severityClass = 'timing-good';
|
||||
const maxInterval = player.max_interval || 0;
|
||||
const avgInterval = player.avg_interval || 0;
|
||||
const totalMessages = player.total_messages || 0;
|
||||
|
||||
if (maxInterval > 60) severityClass = 'timing-critical';
|
||||
else if (maxInterval > 45) severityClass = 'timing-warning';
|
||||
|
||||
const issue = maxInterval > 30 ?
|
||||
`Max gap: ${maxInterval.toFixed(1)}s (>${30}s threshold)` :
|
||||
`Irregular timing patterns detected`;
|
||||
|
||||
return `
|
||||
<div class="timing-player ${severityClass}">
|
||||
<div class="name">${player.character_name || 'Unknown'}</div>
|
||||
<div class="issue">${issue}</div>
|
||||
<div class="stats">
|
||||
Messages: ${totalMessages} |
|
||||
Avg gap: ${avgInterval.toFixed(1)}s |
|
||||
Max gap: ${maxInterval.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Update telemetry timing section
|
||||
const telemetryDiv = document.getElementById('telemetryTiming');
|
||||
if (timingData.summary.total_tracked_players === 0) {
|
||||
telemetryDiv.innerHTML = '<p style="color: #888; font-style: italic;">No telemetry data available</p>';
|
||||
} else {
|
||||
const healthyCount = timingData.summary.total_tracked_players - timingData.problem_players.length;
|
||||
telemetryDiv.innerHTML = `
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||||
<span>Total Players Tracked:</span>
|
||||
<span style="color: #88f; font-weight: bold;">${timingData.summary.total_tracked_players}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||||
<span>Healthy Timing:</span>
|
||||
<span style="color: #4f4; font-weight: bold;">${healthyCount}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>Timing Issues:</span>
|
||||
<span style="color: #f44; font-weight: bold;">${timingData.problem_players.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.9rem; color: #aaa; line-height: 1.4;">
|
||||
Players with telemetry gaps >30s are flagged as timing issues.
|
||||
This can cause players to temporarily disappear from the active list.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Update WebSocket health data
|
||||
const websocketData = data.websocket_health;
|
||||
if (websocketData) {
|
||||
// Update stat card
|
||||
document.getElementById('websocketConnections').textContent =
|
||||
`${websocketData.total_connections} (P:${websocketData.plugin_connections}/B:${websocketData.browser_connections})`;
|
||||
|
||||
// Update detailed section
|
||||
const websocketDiv = document.getElementById('websocketHealth');
|
||||
websocketDiv.innerHTML = `
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||||
<span>Plugin Connections:</span>
|
||||
<span style="color: #88f; font-weight: bold;">${websocketData.plugin_connections}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||||
<span>Browser Connections:</span>
|
||||
<span style="color: #4f4; font-weight: bold;">${websocketData.browser_connections}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>Total Connections:</span>
|
||||
<span style="color: #ff8; font-weight: bold;">${websocketData.total_connections}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.9rem; color: #aaa;">
|
||||
Plugin connections receive telemetry from game clients.<br>
|
||||
Browser connections show live updates to web users.
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('websocketConnections').textContent = 'Error';
|
||||
document.getElementById('websocketHealth').innerHTML = '<p style="color: #f44;">Failed to load WebSocket data</p>';
|
||||
}
|
||||
|
||||
// Update database performance data
|
||||
const databaseData = data.database_performance;
|
||||
if (databaseData) {
|
||||
// Update stat card
|
||||
document.getElementById('databaseQueries').textContent =
|
||||
`${databaseData.total_queries} (${databaseData.average_query_time}s avg)`;
|
||||
|
||||
// Update detailed section
|
||||
const databaseDiv = document.getElementById('databasePerformance');
|
||||
databaseDiv.innerHTML = `
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||||
<span>Total Queries:</span>
|
||||
<span style="color: #88f; font-weight: bold;">${databaseData.total_queries}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||||
<span>Total Query Time:</span>
|
||||
<span style="color: #4f4; font-weight: bold;">${databaseData.total_query_time}s</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>Average Query Time:</span>
|
||||
<span style="color: #ff8; font-weight: bold;">${databaseData.average_query_time}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.9rem; color: #aaa;">
|
||||
Tracks telemetry database write operations performance.<br>
|
||||
Each telemetry message triggers 1-2 database queries.
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('databaseQueries').textContent = 'Error';
|
||||
document.getElementById('databasePerformance').innerHTML = '<p style="color: #f44;">Failed to load database performance data</p>';
|
||||
}
|
||||
|
||||
// Update recent activity data
|
||||
const activityData = data.recent_activity;
|
||||
if (activityData) {
|
||||
// Update stat card
|
||||
document.getElementById('recentActivityCount').textContent =
|
||||
`${activityData.total_messages}/${activityData.max_messages}`;
|
||||
|
||||
// Update detailed section
|
||||
const activityDiv = document.getElementById('recentActivityFeed');
|
||||
if (activityData.recent_messages.length === 0) {
|
||||
activityDiv.innerHTML = '<p style="color: #888; font-style: italic;">No recent activity</p>';
|
||||
} else {
|
||||
activityDiv.innerHTML = activityData.recent_messages.slice().reverse().map(msg => {
|
||||
const timeStr = formatTimestamp(msg.timestamp);
|
||||
const killInfo = msg.kill_delta > 0 ? ` (+${msg.kill_delta} kills)` : '';
|
||||
const queryTime = msg.query_time ? ` ${msg.query_time}ms` : '';
|
||||
return `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid #333; font-family: monospace; font-size: 0.85rem;">
|
||||
<span>
|
||||
<span style="color: #888;">${timeStr}</span>
|
||||
<span style="color: #88f; margin-left: 8px;">${msg.character_name}</span>
|
||||
<span style="color: #aaa; margin-left: 8px;">kills:${msg.kills}${killInfo}</span>
|
||||
</span>
|
||||
<span style="color: #666;">${queryTime}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
document.getElementById('recentActivityCount').textContent = 'Error';
|
||||
document.getElementById('recentActivityFeed').innerHTML = '<p style="color: #f44;">Failed to load recent activity data</p>';
|
||||
}
|
||||
|
||||
// Update last updated
|
||||
document.getElementById('lastUpdated').textContent = `Last updated: ${formatDate(new Date().toISOString())}`;
|
||||
}
|
||||
|
||||
function showSuccess() {
|
||||
document.getElementById('status').style.display = 'none';
|
||||
document.getElementById('statsGrid').style.display = 'grid';
|
||||
document.getElementById('sections').style.display = 'grid';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<div class="error">❌ ${message}</div>`;
|
||||
statusDiv.style.display = 'block';
|
||||
document.getElementById('statsGrid').style.display = 'none';
|
||||
document.getElementById('sections').style.display = 'none';
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (isPolling) return;
|
||||
|
||||
isPolling = true;
|
||||
fetchDebugData(); // Initial fetch
|
||||
pollInterval = setInterval(fetchDebugData, 5000); // Poll every 5 seconds
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
isPolling = false;
|
||||
}
|
||||
|
||||
// Handle page visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling when page loads
|
||||
window.addEventListener('load', startPolling);
|
||||
|
||||
// Stop polling when page unloads
|
||||
window.addEventListener('beforeunload', stopPolling);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -46,20 +46,6 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Suitbuilder link -->
|
||||
<div class="suitbuilder-link">
|
||||
<a href="#" id="suitbuilderBtn" onclick="openSuitbuilder()">
|
||||
🛡️ Suitbuilder
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Player Debug link -->
|
||||
<div class="debug-link">
|
||||
<a href="#" id="playerDebugBtn" onclick="openPlayerDebug()">
|
||||
🔍 Player Debug
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Text input to filter active players by name -->
|
||||
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,715 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Inventory Search - Dereth Tracker</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
/* Override main app styles to enable scrolling */
|
||||
html, body {
|
||||
overflow: auto !important;
|
||||
height: auto !important;
|
||||
display: block !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Inventory-specific styles */
|
||||
.inventory-container {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.character-sidebar {
|
||||
width: 120px;
|
||||
background: white;
|
||||
border: 1px solid #999;
|
||||
padding: 3px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.character-sidebar h4 {
|
||||
margin: 0 0 3px 0;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.character-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.character-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.character-item input[type="checkbox"] {
|
||||
margin: 0 2px 0 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.character-item label {
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inventory-header {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inventory-header h1 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
background: white;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: bold;
|
||||
font-size: 10px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
border: 1px solid #999;
|
||||
padding: 1px 3px;
|
||||
font-size: 11px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-weight: bold;
|
||||
font-size: 10px;
|
||||
min-width: 50px;
|
||||
padding-top: 2px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
margin: 0 1px 0 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.checkbox-item label {
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #999;
|
||||
background: #e0e0e0;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4a80c0;
|
||||
color: white;
|
||||
border-color: #336699;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #336699;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
background: white;
|
||||
border: 1px solid #999;
|
||||
margin-top: 5px;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
background: #d0d0d0;
|
||||
padding: 1px 3px;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border: 1px solid #999;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.results-table td {
|
||||
padding: 1px 3px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.results-table tbody tr:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #0066cc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-equipped {
|
||||
color: #008800;
|
||||
}
|
||||
|
||||
.status-inventory {
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #5f6368;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #5f6368;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: #d93025;
|
||||
background: #fef7e0;
|
||||
margin: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.results-info {
|
||||
padding: 3px 5px;
|
||||
background: #f0f0f0;
|
||||
border-bottom: 1px solid #999;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #1557b0;
|
||||
}
|
||||
|
||||
.back-link::before {
|
||||
content: "←";
|
||||
margin-right: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Set Analysis specific styles */
|
||||
.set-analysis-section {
|
||||
background: white;
|
||||
border: 1px solid #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.set-analysis-form {
|
||||
background: #f8f8f8;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.set-analysis-form h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.set-analysis-form select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="inventory-header">
|
||||
<h1>Inventory Search</h1>
|
||||
<a href="/" style="color: white; text-decoration: none; font-size: 11px;">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="inventory-container">
|
||||
<!-- Character Selection Sidebar -->
|
||||
<div class="character-sidebar">
|
||||
<h4>Characters</h4>
|
||||
<div class="character-item">
|
||||
<input type="checkbox" id="char_all" checked>
|
||||
<label for="char_all">All Characters</label>
|
||||
</div>
|
||||
<div class="character-list" id="characterList">
|
||||
<!-- Character checkboxes will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<form class="search-form" id="inventorySearchForm">
|
||||
<!-- Row 0: Equipment Type Selection -->
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Type:</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<label style="display: flex; align-items: center; font-weight: normal;">
|
||||
<input type="radio" name="equipmentType" id="armorOnly" value="armor" checked style="margin-right: 3px;">
|
||||
Armor Only
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; font-weight: normal;">
|
||||
<input type="radio" name="equipmentType" id="jewelryOnly" value="jewelry" style="margin-right: 3px;">
|
||||
Jewelry Only
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; font-weight: normal;">
|
||||
<input type="radio" name="equipmentType" id="allItems" value="all" style="margin-right: 3px;">
|
||||
All Items
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 0.5: Slot Selection -->
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Slot:</label>
|
||||
<select id="slotFilter">
|
||||
<option value="">All Slots</option>
|
||||
<optgroup label="Armor Slots">
|
||||
<option value="Head">Head</option>
|
||||
<option value="Chest">Chest</option>
|
||||
<option value="Upper Arms">Upper Arms</option>
|
||||
<option value="Lower Arms">Lower Arms</option>
|
||||
<option value="Hands">Hands</option>
|
||||
<option value="Abdomen">Abdomen</option>
|
||||
<option value="Upper Legs">Upper Legs</option>
|
||||
<option value="Lower Legs">Lower Legs</option>
|
||||
<option value="Feet">Feet</option>
|
||||
<option value="Shield">Shield</option>
|
||||
</optgroup>
|
||||
<optgroup label="Jewelry Slots">
|
||||
<option value="Neck">Neck</option>
|
||||
<option value="Left Ring">Left Ring</option>
|
||||
<option value="Right Ring">Right Ring</option>
|
||||
<option value="Left Wrist">Left Wrist</option>
|
||||
<option value="Right Wrist">Right Wrist</option>
|
||||
<option value="Trinket">Trinket</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Basic filters -->
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Name:</label>
|
||||
<input type="text" id="searchText" placeholder="Item name">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Status:</label>
|
||||
<select id="searchEquipStatus">
|
||||
<option value="all">All</option>
|
||||
<option value="equipped">Equipped</option>
|
||||
<option value="unequipped">Inventory</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Stats -->
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Armor:</label>
|
||||
<input type="number" id="searchMinArmor" placeholder="Min">
|
||||
<span>-</span>
|
||||
<input type="number" id="searchMaxArmor" placeholder="Max">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Crit:</label>
|
||||
<input type="number" id="searchMinCritDamage" placeholder="Min">
|
||||
<span>-</span>
|
||||
<input type="number" id="searchMaxCritDamage" placeholder="Max">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Dmg:</label>
|
||||
<input type="number" id="searchMinDamageRating" placeholder="Min">
|
||||
<span>-</span>
|
||||
<input type="number" id="searchMaxDamageRating" placeholder="Max">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Heal:</label>
|
||||
<input type="number" id="searchMinHealBoost" placeholder="Min">
|
||||
<span>-</span>
|
||||
<input type="number" id="searchMaxHealBoost" placeholder="Max">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Sets -->
|
||||
<div class="filter-section">
|
||||
<label class="section-label">Set:</label>
|
||||
<div class="checkbox-container" id="equipmentSets">
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_14" value="14">
|
||||
<label for="set_14">Adept's</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_16" value="16">
|
||||
<label for="set_16">Defender's</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_13" value="13">
|
||||
<label for="set_13">Soldier's</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_21" value="21">
|
||||
<label for="set_21">Wise</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_40" value="40">
|
||||
<label for="set_40">Heroic Protector</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_41" value="41">
|
||||
<label for="set_41">Heroic Destroyer</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_46" value="46">
|
||||
<label for="set_46">Relic Alduressa</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_47" value="47">
|
||||
<label for="set_47">Ancient Relic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_48" value="48">
|
||||
<label for="set_48">Noble Relic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_15" value="15">
|
||||
<label for="set_15">Archer's</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_19" value="19">
|
||||
<label for="set_19">Hearty</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_20" value="20">
|
||||
<label for="set_20">Dexterous</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_22" value="22">
|
||||
<label for="set_22">Swift</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_24" value="24">
|
||||
<label for="set_24">Reinforced</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_26" value="26">
|
||||
<label for="set_26">Flame Proof</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="set_29" value="29">
|
||||
<label for="set_29">Lightning Proof</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legendary Cantrips -->
|
||||
<div class="filter-section">
|
||||
<label class="section-label">Cantrips:</label>
|
||||
<div class="checkbox-container" id="cantrips">
|
||||
<!-- Legendary Attributes -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_strength" value="Legendary Strength">
|
||||
<label for="cantrip_legendary_strength">Strength</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_endurance" value="Legendary Endurance">
|
||||
<label for="cantrip_legendary_endurance">Endurance</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_quickness" value="Legendary Quickness">
|
||||
<label for="cantrip_legendary_quickness">Quickness</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_coordination" value="Legendary Coordination">
|
||||
<label for="cantrip_legendary_coordination">Coordination</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_willpower" value="Legendary Willpower">
|
||||
<label for="cantrip_legendary_willpower">Willpower</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_focus" value="Legendary Focus">
|
||||
<label for="cantrip_legendary_focus">Focus</label>
|
||||
</div>
|
||||
<!-- Legendary Weapon Skills -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_finesse" value="Legendary Finesse Weapons">
|
||||
<label for="cantrip_legendary_finesse">Finesse</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_heavy" value="Legendary Heavy Weapons">
|
||||
<label for="cantrip_legendary_heavy">Heavy</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_light" value="Legendary Light Weapons">
|
||||
<label for="cantrip_legendary_light">Light</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_missile" value="Legendary Missile Weapons">
|
||||
<label for="cantrip_legendary_missile">Missile</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_twohanded" value="Legendary Two Handed Combat">
|
||||
<label for="cantrip_legendary_twohanded">Two-handed</label>
|
||||
</div>
|
||||
<!-- Legendary Magic Skills -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_war" value="Legendary War Magic">
|
||||
<label for="cantrip_legendary_war">War Magic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_void" value="Legendary Void Magic">
|
||||
<label for="cantrip_legendary_void">Void Magic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_creature" value="Legendary Creature Enchantment">
|
||||
<label for="cantrip_legendary_creature">Creature</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_item" value="Legendary Item Enchantment">
|
||||
<label for="cantrip_legendary_item">Item</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_life" value="Legendary Life Magic">
|
||||
<label for="cantrip_legendary_life">Life Magic</label>
|
||||
</div>
|
||||
<!-- Legendary Defense -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_magic_defense" value="Legendary Magic Defense">
|
||||
<label for="cantrip_legendary_magic_defense">Magic Def</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_melee_defense" value="Legendary Melee Defense">
|
||||
<label for="cantrip_legendary_melee_defense">Melee Def</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legendary Wards -->
|
||||
<div class="filter-section">
|
||||
<label class="section-label">Wards:</label>
|
||||
<div class="checkbox-container" id="protections">
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_flame" value="Legendary Flame Ward">
|
||||
<label for="protection_flame">Flame</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_frost" value="Legendary Frost Ward">
|
||||
<label for="protection_frost">Frost</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_acid" value="Legendary Acid Ward">
|
||||
<label for="protection_acid">Acid</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_storm" value="Legendary Storm Ward">
|
||||
<label for="protection_storm">Storm</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_slashing" value="Legendary Slashing Ward">
|
||||
<label for="protection_slashing">Slashing</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_piercing" value="Legendary Piercing Ward">
|
||||
<label for="protection_piercing">Piercing</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_bludgeoning" value="Legendary Bludgeoning Ward">
|
||||
<label for="protection_bludgeoning">Bludgeoning</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_armor" value="Legendary Armor">
|
||||
<label for="protection_armor">Armor</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-actions">
|
||||
<button type="button" class="btn btn-secondary" id="clearBtn">Clear All</button>
|
||||
<button type="submit" class="btn btn-primary">Search Items</button>
|
||||
<button type="button" class="btn btn-secondary" id="setAnalysisBtn">Analyze Sets</button>
|
||||
<button type="button" class="btn btn-secondary" id="slotViewBtn">Slot View</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Set Analysis Section -->
|
||||
<div class="set-analysis-section" id="setAnalysisSection" style="display: none;">
|
||||
<div class="set-analysis-form">
|
||||
<h3>Equipment Set Combination Analysis</h3>
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Primary Set (5 pieces):</label>
|
||||
<select id="primarySetSelect">
|
||||
<option value="14">Adept's Set</option>
|
||||
<option value="16">Defender's Set</option>
|
||||
<option value="13">Soldier's Set</option>
|
||||
<option value="21">Wise Set</option>
|
||||
<option value="40">Heroic Protector</option>
|
||||
<option value="41">Heroic Destroyer</option>
|
||||
<option value="46">Relic Alduressa</option>
|
||||
<option value="47">Ancient Relic</option>
|
||||
<option value="48">Noble Relic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Secondary Set (4 pieces):</label>
|
||||
<select id="secondarySetSelect">
|
||||
<option value="13">Soldier's Set</option>
|
||||
<option value="14">Adept's Set</option>
|
||||
<option value="16">Defender's Set</option>
|
||||
<option value="21">Wise Set</option>
|
||||
<option value="40">Heroic Protector</option>
|
||||
<option value="41">Heroic Destroyer</option>
|
||||
<option value="46">Relic Alduressa</option>
|
||||
<option value="47">Ancient Relic</option>
|
||||
<option value="48">Noble Relic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button type="button" class="btn btn-primary" id="runSetAnalysis">Analyze</button>
|
||||
<button type="button" class="btn btn-secondary" id="backToSearch">Back to Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-container" id="setAnalysisResults">
|
||||
<div class="no-results">Select primary and secondary sets above and click "Analyze" to find valid combinations.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot View Section -->
|
||||
<div class="slot-view-section" id="slotViewSection" style="display: none;">
|
||||
<div class="slot-view-header">
|
||||
<h3>Equipment Slot View</h3>
|
||||
<div class="filter-row">
|
||||
<button type="button" class="btn btn-primary" id="loadSlotView">Load Items</button>
|
||||
<button type="button" class="btn btn-secondary" id="backToSearchFromSlots">Back to Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slots-grid" id="slotsGrid">
|
||||
<!-- Slots will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-container" id="searchResults">
|
||||
<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="inventory.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,722 +0,0 @@
|
|||
/**
|
||||
* Inventory Search Application
|
||||
* Dedicated JavaScript for the inventory search page
|
||||
*/
|
||||
|
||||
// Configuration - use main app proxy for inventory service
|
||||
const API_BASE = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
? 'http://localhost:8766' // Local development - direct to inventory service
|
||||
: `${window.location.origin}/inv`; // Production - through main app proxy
|
||||
|
||||
// DOM Elements - will be set after DOM loads
|
||||
let searchForm, clearBtn, searchResults;
|
||||
|
||||
// Sorting state
|
||||
let currentSort = {
|
||||
field: 'name',
|
||||
direction: 'asc'
|
||||
};
|
||||
|
||||
// Store current search results for client-side sorting
|
||||
let currentResultsData = null;
|
||||
|
||||
// Initialize the application
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get DOM elements after DOM is loaded
|
||||
searchForm = document.getElementById('inventorySearchForm');
|
||||
clearBtn = document.getElementById('clearBtn');
|
||||
searchResults = document.getElementById('searchResults');
|
||||
|
||||
|
||||
initializeEventListeners();
|
||||
loadCharacterOptions();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize all event listeners
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// Form submission
|
||||
searchForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await performSearch();
|
||||
});
|
||||
|
||||
// Clear button
|
||||
clearBtn.addEventListener('click', clearAllFields);
|
||||
|
||||
// Slot filter change
|
||||
document.getElementById('slotFilter').addEventListener('change', handleSlotFilterChange);
|
||||
|
||||
// Set analysis buttons
|
||||
document.getElementById('setAnalysisBtn').addEventListener('click', showSetAnalysis);
|
||||
document.getElementById('backToSearch').addEventListener('click', showSearchSection);
|
||||
document.getElementById('runSetAnalysis').addEventListener('click', performSetAnalysis);
|
||||
|
||||
// Checkbox visual feedback for cantrips and equipment sets
|
||||
document.querySelectorAll('.checkbox-item input[type="checkbox"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', handleCheckboxChange);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available characters for the checkbox list
|
||||
*/
|
||||
async function loadCharacterOptions() {
|
||||
try {
|
||||
// Use inventory service proxy endpoint for character list
|
||||
const response = await fetch(`${window.location.origin}/inventory-characters`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.characters && data.characters.length > 0) {
|
||||
const characterList = document.getElementById('characterList');
|
||||
|
||||
// Sort characters by name
|
||||
data.characters.sort((a, b) => a.character_name.localeCompare(b.character_name));
|
||||
|
||||
// Add character checkboxes
|
||||
data.characters.forEach(char => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'character-item';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = `char_${char.character_name}`;
|
||||
checkbox.value = char.character_name;
|
||||
checkbox.className = 'character-checkbox';
|
||||
checkbox.checked = true; // Check all by default
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = checkbox.id;
|
||||
label.textContent = char.character_name;
|
||||
|
||||
div.appendChild(checkbox);
|
||||
div.appendChild(label);
|
||||
characterList.appendChild(div);
|
||||
});
|
||||
|
||||
// Set up event listeners for character selection
|
||||
setupCharacterCheckboxes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load character list:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup character checkbox functionality
|
||||
*/
|
||||
function setupCharacterCheckboxes() {
|
||||
const allCheckbox = document.getElementById('char_all');
|
||||
const characterCheckboxes = document.querySelectorAll('.character-checkbox');
|
||||
|
||||
// Handle "All Characters" checkbox
|
||||
allCheckbox.addEventListener('change', function() {
|
||||
characterCheckboxes.forEach(cb => {
|
||||
cb.checked = this.checked;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle individual character checkboxes
|
||||
characterCheckboxes.forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
// If any individual checkbox is unchecked, uncheck "All"
|
||||
if (!this.checked) {
|
||||
allCheckbox.checked = false;
|
||||
} else {
|
||||
// If all individual checkboxes are checked, check "All"
|
||||
const allChecked = Array.from(characterCheckboxes).every(checkbox => checkbox.checked);
|
||||
allCheckbox.checked = allChecked;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle checkbox change events for visual feedback
|
||||
*/
|
||||
function handleCheckboxChange(e) {
|
||||
const item = e.target.closest('.checkbox-item');
|
||||
if (e.target.checked) {
|
||||
item.classList.add('checked');
|
||||
} else {
|
||||
item.classList.remove('checked');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all form fields and checkboxes
|
||||
*/
|
||||
function clearAllFields() {
|
||||
searchForm.reset();
|
||||
|
||||
// Reset character selection to "All"
|
||||
document.getElementById('char_all').checked = true;
|
||||
document.querySelectorAll('.character-checkbox').forEach(cb => {
|
||||
cb.checked = true;
|
||||
});
|
||||
|
||||
// Clear checkbox visual states
|
||||
document.querySelectorAll('.checkbox-item').forEach(item => {
|
||||
item.classList.remove('checked');
|
||||
});
|
||||
|
||||
// Reset equipment type to armor
|
||||
document.getElementById('armorOnly').checked = true;
|
||||
|
||||
// Reset slot filter
|
||||
document.getElementById('slotFilter').value = '';
|
||||
|
||||
// Reset results and clear stored data
|
||||
currentResultsData = null;
|
||||
searchResults.innerHTML = '<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle slot filter changes
|
||||
*/
|
||||
function handleSlotFilterChange() {
|
||||
// If we have current results, reapply filtering and sorting
|
||||
if (currentResultsData) {
|
||||
// Reset items to original unfiltered data
|
||||
const originalData = JSON.parse(JSON.stringify(currentResultsData));
|
||||
|
||||
// Apply slot filtering
|
||||
applySlotFilter(originalData);
|
||||
|
||||
// Apply sorting
|
||||
sortResults(originalData);
|
||||
|
||||
// Display results
|
||||
displayResults(originalData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform the search based on form inputs
|
||||
*/
|
||||
async function performSearch() {
|
||||
searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>';
|
||||
|
||||
try {
|
||||
const params = buildSearchParameters();
|
||||
const searchUrl = `${API_BASE}/search/items?${params.toString()}`;
|
||||
console.log('Search URL:', searchUrl);
|
||||
|
||||
const response = await fetch(searchUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Search failed');
|
||||
}
|
||||
|
||||
// Store results for client-side re-sorting
|
||||
currentResultsData = data;
|
||||
|
||||
// Apply client-side slot filtering
|
||||
applySlotFilter(data);
|
||||
|
||||
// Sort the results client-side before displaying
|
||||
sortResults(data);
|
||||
displayResults(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchResults.innerHTML = `<div class="error">❌ Search failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build search parameters from form inputs
|
||||
*/
|
||||
function buildSearchParameters() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Equipment type selection
|
||||
const equipmentType = document.querySelector('input[name="equipmentType"]:checked').value;
|
||||
if (equipmentType === 'armor') {
|
||||
params.append('armor_only', 'true');
|
||||
} else if (equipmentType === 'jewelry') {
|
||||
params.append('jewelry_only', 'true');
|
||||
}
|
||||
// If 'all' is selected, don't add any type filter
|
||||
|
||||
// Basic search parameters - handle character selection
|
||||
const allCharactersChecked = document.getElementById('char_all').checked;
|
||||
if (!allCharactersChecked) {
|
||||
// Get selected characters
|
||||
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
if (selectedCharacters.length === 1) {
|
||||
// Single character selected
|
||||
params.append('character', selectedCharacters[0]);
|
||||
} else if (selectedCharacters.length > 1) {
|
||||
// Multiple characters - use comma-separated list
|
||||
params.append('characters', selectedCharacters.join(','));
|
||||
} else {
|
||||
// No characters selected - search nothing
|
||||
return { items: [], total_count: 0, page: 1, total_pages: 0 };
|
||||
}
|
||||
} else {
|
||||
// All characters selected
|
||||
params.append('include_all_characters', 'true');
|
||||
}
|
||||
addParam(params, 'text', 'searchText');
|
||||
addParam(params, 'material', 'searchMaterial');
|
||||
|
||||
const equipStatus = document.getElementById('searchEquipStatus').value;
|
||||
if (equipStatus && equipStatus !== 'all') {
|
||||
params.append('equipment_status', equipStatus);
|
||||
}
|
||||
|
||||
// Armor statistics parameters
|
||||
addParam(params, 'min_armor', 'searchMinArmor');
|
||||
addParam(params, 'max_armor', 'searchMaxArmor');
|
||||
addParam(params, 'min_crit_damage_rating', 'searchMinCritDamage');
|
||||
addParam(params, 'max_crit_damage_rating', 'searchMaxCritDamage');
|
||||
addParam(params, 'min_damage_rating', 'searchMinDamageRating');
|
||||
addParam(params, 'max_damage_rating', 'searchMaxDamageRating');
|
||||
addParam(params, 'min_heal_boost_rating', 'searchMinHealBoost');
|
||||
addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost');
|
||||
|
||||
// Requirements parameters
|
||||
addParam(params, 'min_level', 'searchMinLevel');
|
||||
addParam(params, 'max_level', 'searchMaxLevel');
|
||||
addParam(params, 'min_workmanship', 'searchMinWorkmanship');
|
||||
addParam(params, 'max_workmanship', 'searchMaxWorkmanship');
|
||||
|
||||
// Value parameters
|
||||
addParam(params, 'min_value', 'searchMinValue');
|
||||
addParam(params, 'max_value', 'searchMaxValue');
|
||||
addParam(params, 'max_burden', 'searchMaxBurden');
|
||||
|
||||
// Equipment set filters
|
||||
const selectedEquipmentSets = getSelectedEquipmentSets();
|
||||
if (selectedEquipmentSets.length === 1) {
|
||||
params.append('item_set', selectedEquipmentSets[0]);
|
||||
} else if (selectedEquipmentSets.length > 1) {
|
||||
params.append('item_sets', selectedEquipmentSets.join(','));
|
||||
}
|
||||
|
||||
// Cantrip filters
|
||||
const selectedCantrips = getSelectedCantrips();
|
||||
const selectedProtections = getSelectedProtections();
|
||||
const allSpells = [...selectedCantrips, ...selectedProtections];
|
||||
if (allSpells.length > 0) {
|
||||
params.append('legendary_cantrips', allSpells.join(','));
|
||||
}
|
||||
|
||||
// Pagination only - sorting will be done client-side
|
||||
params.append('limit', '1000'); // Show all items on one page
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add parameter if value exists
|
||||
*/
|
||||
function addParam(params, paramName, elementId) {
|
||||
const value = document.getElementById(elementId)?.value?.trim();
|
||||
if (value) {
|
||||
params.append(paramName, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected equipment sets
|
||||
*/
|
||||
function getSelectedEquipmentSets() {
|
||||
const selectedSets = [];
|
||||
document.querySelectorAll('#equipmentSets input[type="checkbox"]:checked').forEach(cb => {
|
||||
selectedSets.push(cb.value);
|
||||
});
|
||||
return selectedSets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected legendary cantrips
|
||||
*/
|
||||
function getSelectedCantrips() {
|
||||
const selectedCantrips = [];
|
||||
document.querySelectorAll('#cantrips input[type="checkbox"]:checked').forEach(cb => {
|
||||
selectedCantrips.push(cb.value);
|
||||
});
|
||||
return selectedCantrips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected protection spells
|
||||
*/
|
||||
function getSelectedProtections() {
|
||||
const selectedProtections = [];
|
||||
document.querySelectorAll('#protections input[type="checkbox"]:checked').forEach(cb => {
|
||||
selectedProtections.push(cb.value);
|
||||
});
|
||||
return selectedProtections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results in the UI
|
||||
*/
|
||||
function displayResults(data) {
|
||||
if (!data.items || data.items.length === 0) {
|
||||
searchResults.innerHTML = '<div class="no-results">No items found matching your search criteria.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const getSortIcon = (field) => {
|
||||
if (currentSort.field === field) {
|
||||
return currentSort.direction === 'asc' ? ' ▲' : ' ▼';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
let html = `
|
||||
<div class="results-info">
|
||||
Found <strong>${data.total_count}</strong> items - Showing all results
|
||||
</div>
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-sort="character_name">Character${getSortIcon('character_name')}</th>
|
||||
<th class="sortable" data-sort="name">Item Name${getSortIcon('name')}</th>
|
||||
<th class="sortable" data-sort="item_type_name">Type${getSortIcon('item_type_name')}</th>
|
||||
<th class="text-right sortable" data-sort="slot_name">Slot${getSortIcon('slot_name')}</th>
|
||||
<th class="text-right sortable" data-sort="coverage">Coverage${getSortIcon('coverage')}</th>
|
||||
<th class="text-right sortable" data-sort="armor_level">Armor${getSortIcon('armor_level')}</th>
|
||||
<th class="sortable" data-sort="spell_names">Spells/Cantrips${getSortIcon('spell_names')}</th>
|
||||
<th class="text-right sortable" data-sort="crit_damage_rating">Crit Dmg${getSortIcon('crit_damage_rating')}</th>
|
||||
<th class="text-right sortable" data-sort="damage_rating">Dmg Rating${getSortIcon('damage_rating')}</th>
|
||||
<th class="text-right sortable" data-sort="last_updated">Last Updated${getSortIcon('last_updated')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.items.forEach((item) => {
|
||||
const armor = item.armor_level > 0 ? item.armor_level : '-';
|
||||
const critDmg = item.crit_damage_rating > 0 ? item.crit_damage_rating : '-';
|
||||
const dmgRating = item.damage_rating > 0 ? item.damage_rating : '-';
|
||||
const status = item.is_equipped ? '⚔️ Equipped' : '📦 Inventory';
|
||||
const statusClass = item.is_equipped ? 'status-equipped' : 'status-inventory';
|
||||
|
||||
// Use the slot_name provided by the API instead of incorrect mapping
|
||||
const slot = item.slot_name || 'Unknown';
|
||||
|
||||
// Coverage placeholder - will need to be added to backend later
|
||||
const coverage = item.coverage || '-';
|
||||
|
||||
// Format last updated timestamp
|
||||
const lastUpdated = item.last_updated ?
|
||||
new Date(item.last_updated).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) : '-';
|
||||
|
||||
// Use the formatted name with material from the API
|
||||
let displayName = item.name;
|
||||
|
||||
// The API should already include material in the name, but use material_name if available
|
||||
if (item.material_name && item.material_name !== '' && !item.name.toLowerCase().includes(item.material_name.toLowerCase())) {
|
||||
displayName = `${item.material_name} ${item.name}`;
|
||||
}
|
||||
|
||||
// Format spells/cantrips list
|
||||
let spellsDisplay = '-';
|
||||
if (item.spell_names && item.spell_names.length > 0) {
|
||||
// Highlight legendary cantrips in a different color
|
||||
const formattedSpells = item.spell_names.map(spell => {
|
||||
if (spell.toLowerCase().includes('legendary')) {
|
||||
return `<span class="legendary-cantrip">${spell}</span>`;
|
||||
} else {
|
||||
return `<span class="regular-spell">${spell}</span>`;
|
||||
}
|
||||
});
|
||||
spellsDisplay = formattedSpells.join('<br>');
|
||||
}
|
||||
|
||||
// Get item type for display
|
||||
const itemType = item.item_type_name || '-';
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${item.character_name}</td>
|
||||
<td class="item-name">${displayName}</td>
|
||||
<td>${itemType}</td>
|
||||
<td class="text-right">${slot}</td>
|
||||
<td class="text-right">${coverage}</td>
|
||||
<td class="text-right">${armor}</td>
|
||||
<td class="spells-cell">${spellsDisplay}</td>
|
||||
<td class="text-right">${critDmg}</td>
|
||||
<td class="text-right">${dmgRating}</td>
|
||||
<td class="text-right">${lastUpdated}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Add pagination info if needed
|
||||
if (data.total_pages > 1) {
|
||||
html += `
|
||||
<div style="padding: 16px 24px; text-align: center; color: #5f6368; border-top: 1px solid #e8eaed;">
|
||||
Showing page ${data.page} of ${data.total_pages} pages
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
searchResults.innerHTML = html;
|
||||
|
||||
// Add click event listeners to sortable headers
|
||||
document.querySelectorAll('.sortable').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const sortField = header.getAttribute('data-sort');
|
||||
handleSort(sortField);
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Apply client-side slot filtering
|
||||
*/
|
||||
function applySlotFilter(data) {
|
||||
const selectedSlot = document.getElementById('slotFilter').value;
|
||||
|
||||
if (!selectedSlot || !data.items) {
|
||||
return; // No filter or no data
|
||||
}
|
||||
|
||||
// Filter items that can be equipped in the selected slot
|
||||
data.items = data.items.filter(item => {
|
||||
const slotName = item.slot_name || '';
|
||||
|
||||
// Check if the item's slot_name contains the selected slot
|
||||
// This handles multi-slot items like "Left Ring, Right Ring"
|
||||
return slotName.includes(selectedSlot);
|
||||
});
|
||||
|
||||
// Update total count
|
||||
data.total_count = data.items.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort results client-side based on current sort settings
|
||||
*/
|
||||
function sortResults(data) {
|
||||
if (!data.items || data.items.length === 0) return;
|
||||
|
||||
const field = currentSort.field;
|
||||
const direction = currentSort.direction;
|
||||
|
||||
data.items.sort((a, b) => {
|
||||
let aVal = a[field];
|
||||
let bVal = b[field];
|
||||
|
||||
// Handle null/undefined values
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Special handling for spell_names array
|
||||
if (field === 'spell_names') {
|
||||
// Convert arrays to strings for sorting
|
||||
aVal = Array.isArray(aVal) ? aVal.join(', ').toLowerCase() : '';
|
||||
bVal = Array.isArray(bVal) ? bVal.join(', ').toLowerCase() : '';
|
||||
const result = aVal.localeCompare(bVal);
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
|
||||
// Determine if we're sorting numbers or strings
|
||||
const isNumeric = typeof aVal === 'number' || (!isNaN(aVal) && !isNaN(parseFloat(aVal)));
|
||||
|
||||
if (isNumeric) {
|
||||
// Numeric sorting
|
||||
aVal = parseFloat(aVal) || 0;
|
||||
bVal = parseFloat(bVal) || 0;
|
||||
const result = aVal - bVal;
|
||||
return direction === 'asc' ? result : -result;
|
||||
} else {
|
||||
// String sorting
|
||||
aVal = String(aVal).toLowerCase();
|
||||
bVal = String(bVal).toLowerCase();
|
||||
const result = aVal.localeCompare(bVal);
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle column sorting
|
||||
*/
|
||||
function handleSort(field) {
|
||||
// If clicking the same field, toggle direction
|
||||
if (currentSort.field === field) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// New field, default to ascending
|
||||
currentSort.field = field;
|
||||
currentSort.direction = 'asc';
|
||||
}
|
||||
|
||||
// Re-display current results with new sorting (no new search needed)
|
||||
if (currentResultsData) {
|
||||
// Reset items to original unfiltered data
|
||||
const originalData = JSON.parse(JSON.stringify(currentResultsData));
|
||||
|
||||
// Apply slot filtering first
|
||||
applySlotFilter(originalData);
|
||||
|
||||
// Then apply sorting
|
||||
sortResults(originalData);
|
||||
|
||||
// Display results
|
||||
displayResults(originalData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show set analysis section
|
||||
*/
|
||||
function showSetAnalysis() {
|
||||
document.getElementById('setAnalysisSection').style.display = 'block';
|
||||
document.getElementById('searchResults').style.display = 'none';
|
||||
searchForm.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show search section
|
||||
*/
|
||||
function showSearchSection() {
|
||||
document.getElementById('setAnalysisSection').style.display = 'none';
|
||||
document.getElementById('searchResults').style.display = 'block';
|
||||
searchForm.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform set combination analysis
|
||||
*/
|
||||
async function performSetAnalysis() {
|
||||
const primarySet = document.getElementById('primarySetSelect').value;
|
||||
const secondarySet = document.getElementById('secondarySetSelect').value;
|
||||
const setAnalysisResults = document.getElementById('setAnalysisResults');
|
||||
|
||||
if (primarySet === secondarySet) {
|
||||
setAnalysisResults.innerHTML = '<div class="error">❌ Primary and secondary sets must be different.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
setAnalysisResults.innerHTML = '<div class="loading">🔍 Analyzing set combinations...</div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('primary_set', primarySet);
|
||||
params.append('secondary_set', secondarySet);
|
||||
params.append('primary_count', '5');
|
||||
params.append('secondary_count', '4');
|
||||
|
||||
// Use selected characters or all characters
|
||||
const allCharactersChecked = document.getElementById('char_all').checked;
|
||||
if (allCharactersChecked) {
|
||||
params.append('include_all_characters', 'true');
|
||||
} else {
|
||||
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
|
||||
.map(cb => cb.value);
|
||||
if (selectedCharacters.length > 0) {
|
||||
params.append('characters', selectedCharacters.join(','));
|
||||
} else {
|
||||
setAnalysisResults.innerHTML = '<div class="error">❌ Please select at least one character or check "All Characters".</div>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const analysisUrl = `${API_BASE}/analyze/sets?${params.toString()}`;
|
||||
console.log('Set Analysis URL:', analysisUrl);
|
||||
|
||||
const response = await fetch(analysisUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Set analysis failed');
|
||||
}
|
||||
|
||||
displaySetAnalysisResults(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Set analysis error:', error);
|
||||
setAnalysisResults.innerHTML = `<div class="error">❌ Set analysis failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display set analysis results
|
||||
*/
|
||||
function displaySetAnalysisResults(data) {
|
||||
const setAnalysisResults = document.getElementById('setAnalysisResults');
|
||||
|
||||
if (!data.character_analysis || data.character_analysis.length === 0) {
|
||||
setAnalysisResults.innerHTML = '<div class="no-results">No characters found with the selected sets.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="results-info">
|
||||
<strong>${data.primary_set.name}</strong> (${data.primary_set.pieces_needed} pieces) +
|
||||
<strong>${data.secondary_set.name}</strong> (${data.secondary_set.pieces_needed} pieces)<br>
|
||||
Found <strong>${data.characters_can_build}</strong> of <strong>${data.total_characters}</strong> characters who can build this combination
|
||||
</div>
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Character</th>
|
||||
<th>Can Build?</th>
|
||||
<th>${data.primary_set.name}</th>
|
||||
<th>${data.secondary_set.name}</th>
|
||||
<th>Primary Items</th>
|
||||
<th>Secondary Items</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.character_analysis.forEach((char) => {
|
||||
const canBuild = char.can_build_combination;
|
||||
const canBuildText = canBuild ? '✅ Yes' : '❌ No';
|
||||
const canBuildClass = canBuild ? 'status-equipped' : 'status-inventory';
|
||||
|
||||
const primaryStatus = `${char.primary_set_available}/${char.primary_set_needed}`;
|
||||
const secondaryStatus = `${char.secondary_set_available}/${char.secondary_set_needed}`;
|
||||
|
||||
// Format item lists
|
||||
const primaryItems = char.primary_items.map(item =>
|
||||
`${item.name}${item.equipped ? ' ⚔️' : ''}`
|
||||
).join('<br>') || '-';
|
||||
|
||||
const secondaryItems = char.secondary_items.map(item =>
|
||||
`${item.name}${item.equipped ? ' ⚔️' : ''}`
|
||||
).join('<br>') || '-';
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${char.character_name}</strong></td>
|
||||
<td class="${canBuildClass}">${canBuildText}</td>
|
||||
<td class="text-right">${primaryStatus}</td>
|
||||
<td class="text-right">${secondaryStatus}</td>
|
||||
<td class="spells-cell">${primaryItems}</td>
|
||||
<td class="spells-cell">${secondaryItems}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
setAnalysisResults.innerHTML = html;
|
||||
}
|
||||
|
|
@ -1776,20 +1776,4 @@ function openInventorySearch() {
|
|||
window.open('/inventory.html', '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Suitbuilder interface in a new browser tab.
|
||||
*/
|
||||
function openSuitbuilder() {
|
||||
// Open the Suitbuilder page in a new tab
|
||||
window.open('/suitbuilder.html', '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Player Debug interface in a new browser tab.
|
||||
*/
|
||||
function openPlayerDebug() {
|
||||
// Open the Player Debug page in a new tab
|
||||
window.open('/debug.html', '_blank');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1240,62 +1240,6 @@ body.noselect, body.noselect * {
|
|||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
.suitbuilder-link {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid #ff6b4a;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.suitbuilder-link a {
|
||||
color: #ff6b4a;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.suitbuilder-link a:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 107, 74, 0.1);
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
.debug-link {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4aff6b;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debug-link a {
|
||||
color: #4aff6b;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.debug-link a:hover {
|
||||
color: #fff;
|
||||
background: rgba(74, 255, 107, 0.1);
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
/* Sortable column styles for inventory tables */
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue