Compare commits
2 commits
57a2384511
...
80a0a16bab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a0a16bab | ||
|
|
1febf6e918 |
15 changed files with 5809 additions and 318 deletions
|
|
@ -1,95 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"_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
48
FIXES.md
|
|
@ -1,48 +0,0 @@
|
||||||
# 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._
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# 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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
|
|
@ -16,20 +16,27 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
- This project provides:
|
This project provides:
|
||||||
- A FastAPI backend with endpoints for receiving and querying telemetry data.
|
- A FastAPI backend with endpoints for receiving and querying telemetry data.
|
||||||
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
|
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
|
||||||
- A live, interactive map using static HTML, CSS, and JavaScript.
|
- A live, interactive map using static HTML, CSS, and JavaScript.
|
||||||
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
|
- 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
|
## Features
|
||||||
|
|
||||||
- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret).
|
- **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (protected by a shared secret).
|
||||||
- **GET /live**: Fetch active players seen in the last 30 seconds.
|
- **GET /live**: Fetch active players seen in the last 30 seconds.
|
||||||
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
|
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
|
||||||
- **GET /debug**: Health check endpoint.
|
- **GET /debug**: Health check endpoint.
|
||||||
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
|
- **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.
|
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
@ -218,11 +225,13 @@ For a complete reference of JSON payloads accepted by the backend (over `/ws/pos
|
||||||
- **Spawn events** (`type`: "spawn")
|
- **Spawn events** (`type`: "spawn")
|
||||||
- **Chat events** (`type`: "chat")
|
- **Chat events** (`type`: "chat")
|
||||||
- **Rare events** (`type`: "rare")
|
- **Rare events** (`type`: "rare")
|
||||||
|
- **Inventory events** (`type`: "inventory")
|
||||||
|
|
||||||
Notes on payload changes:
|
Notes on payload changes:
|
||||||
- Spawn events no longer require the `z` coordinate; if omitted, the server defaults it to 0.0.
|
- 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.
|
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.
|
- 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.
|
Each entry shows all required and optional fields, their types, and example values.
|
||||||
|
|
||||||
|
|
@ -253,11 +262,14 @@ Response:
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- **Live Map**: `static/index.html` – Real-time player positions on a map.
|
- **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
|
## Database Schema
|
||||||
|
|
||||||
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data
|
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data,
|
||||||
and aggregate character statistics. The primary tables are:
|
aggregate character statistics, and a separate inventory database for equipment management.
|
||||||
|
|
||||||
|
### Telemetry Database Tables:
|
||||||
|
|
||||||
- **telemetry_events** (hypertable):
|
- **telemetry_events** (hypertable):
|
||||||
- `id` (PK, serial)
|
- `id` (PK, serial)
|
||||||
|
|
@ -297,6 +309,41 @@ and aggregate character statistics. The primary tables are:
|
||||||
- `timestamp` (timestamptz)
|
- `timestamp` (timestamptz)
|
||||||
- `ew`, `ns`, `z` (float)
|
- `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
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Feel free to open issues or submit pull requests.
|
Contributions are welcome! Feel free to open issues or submit pull requests.
|
||||||
|
|
|
||||||
62
TODO.md
62
TODO.md
|
|
@ -1,62 +0,0 @@
|
||||||
## 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
|
|
@ -6,4 +6,5 @@ databases[postgresql]==0.8.0
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
python-json-logger==2.0.7
|
python-json-logger==2.0.7
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
sse-starlette==1.8.2
|
||||||
439
main.py
439
main.py
|
|
@ -10,6 +10,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -60,6 +61,249 @@ _cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None}
|
||||||
_cache_task: asyncio.Task | None = None
|
_cache_task: asyncio.Task | None = None
|
||||||
_rares_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:
|
async def _refresh_cache_loop() -> None:
|
||||||
"""Background task: refresh `/live` and `/trails` caches every 5 seconds."""
|
"""Background task: refresh `/live` and `/trails` caches every 5 seconds."""
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
|
|
@ -92,7 +336,12 @@ async def _refresh_cache_loop() -> None:
|
||||||
# Use a single connection for both queries to reduce connection churn
|
# Use a single connection for both queries to reduce connection churn
|
||||||
async with database.connection() as conn:
|
async with database.connection() as conn:
|
||||||
rows = await conn.fetch_all(sql_live, {"cutoff": cutoff})
|
rows = await conn.fetch_all(sql_live, {"cutoff": cutoff})
|
||||||
_cached_live["players"] = [dict(r) for r in rows]
|
new_players = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# Track player changes for debugging
|
||||||
|
_track_player_changes(new_players)
|
||||||
|
|
||||||
|
_cached_live["players"] = new_players
|
||||||
|
|
||||||
# Recompute trails (last 600s)
|
# Recompute trails (last 600s)
|
||||||
cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600)
|
cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600)
|
||||||
|
|
@ -347,6 +596,104 @@ async def on_shutdown():
|
||||||
def debug():
|
def debug():
|
||||||
return {"status": "OK"}
|
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)
|
||||||
@app.get("/live/", response_model=dict)
|
@app.get("/live/", response_model=dict)
|
||||||
|
|
@ -899,6 +1246,8 @@ async def ws_receive_snapshots(
|
||||||
- rare: update total and session rare counts, persist event
|
- rare: update total and session rare counts, persist event
|
||||||
- chat: broadcast chat messages to browsers
|
- chat: broadcast chat messages to browsers
|
||||||
"""
|
"""
|
||||||
|
global _plugin_connections
|
||||||
|
|
||||||
# Authenticate plugin connection using shared secret
|
# Authenticate plugin connection using shared secret
|
||||||
key = secret or x_plugin_secret
|
key = secret or x_plugin_secret
|
||||||
if key != SHARED_SECRET:
|
if key != SHARED_SECRET:
|
||||||
|
|
@ -908,7 +1257,11 @@ async def ws_receive_snapshots(
|
||||||
return
|
return
|
||||||
# Accept the WebSocket connection
|
# Accept the WebSocket connection
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
logger.info(f"Plugin WebSocket connected: {websocket.client}")
|
logger.info(f"🔌 PLUGIN_CONNECTED: {websocket.client}")
|
||||||
|
|
||||||
|
# Track plugin connection
|
||||||
|
_plugin_connections += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Read next text frame
|
# Read next text frame
|
||||||
|
|
@ -917,7 +1270,7 @@ async def ws_receive_snapshots(
|
||||||
# Debug: log all incoming plugin WebSocket messages
|
# Debug: log all incoming plugin WebSocket messages
|
||||||
logger.debug(f"Plugin WebSocket RX from {websocket.client}: {raw}")
|
logger.debug(f"Plugin WebSocket RX from {websocket.client}: {raw}")
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info(f"Plugin WebSocket disconnected: {websocket.client}")
|
logger.info(f"🔌 PLUGIN_DISCONNECTED: {websocket.client}")
|
||||||
break
|
break
|
||||||
# Parse JSON payload
|
# Parse JSON payload
|
||||||
try:
|
try:
|
||||||
|
|
@ -931,7 +1284,7 @@ async def ws_receive_snapshots(
|
||||||
name = data.get("character_name") or data.get("player_name")
|
name = data.get("character_name") or data.get("player_name")
|
||||||
if isinstance(name, str):
|
if isinstance(name, str):
|
||||||
plugin_conns[name] = websocket
|
plugin_conns[name] = websocket
|
||||||
logger.info(f"Registered plugin connection for character: {name}")
|
logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}")
|
||||||
continue
|
continue
|
||||||
# --- Spawn event: persist to spawn_events table ---
|
# --- Spawn event: persist to spawn_events table ---
|
||||||
if msg_type == "spawn":
|
if msg_type == "spawn":
|
||||||
|
|
@ -952,6 +1305,12 @@ async def ws_receive_snapshots(
|
||||||
# Parse telemetry snapshot and update in-memory state
|
# Parse telemetry snapshot and update in-memory state
|
||||||
payload = data.copy()
|
payload = data.copy()
|
||||||
payload.pop("type", None)
|
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:
|
try:
|
||||||
snap = TelemetrySnapshot.parse_obj(payload)
|
snap = TelemetrySnapshot.parse_obj(payload)
|
||||||
live_snapshots[snap.character_name] = snap.dict()
|
live_snapshots[snap.character_name] = snap.dict()
|
||||||
|
|
@ -974,6 +1333,16 @@ async def ws_receive_snapshots(
|
||||||
|
|
||||||
delta = snap.kills - last
|
delta = snap.kills - last
|
||||||
# Persist snapshot and any kill delta in a single transaction
|
# 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:
|
try:
|
||||||
async with database.transaction():
|
async with database.transaction():
|
||||||
await database.execute(
|
await database.execute(
|
||||||
|
|
@ -989,15 +1358,60 @@ async def ws_receive_snapshots(
|
||||||
)
|
)
|
||||||
await database.execute(stmt)
|
await database.execute(stmt)
|
||||||
logger.debug(f"Updated kills for {snap.character_name}: +{delta} (total from {last} to {snap.kills})")
|
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
|
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:
|
except Exception as db_error:
|
||||||
logger.error(f"💾 Database transaction failed for {snap.character_name} (session: {snap.session_id[:8]}): {db_error}", exc_info=True)
|
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)
|
||||||
continue
|
continue
|
||||||
# Broadcast updated snapshot to all browser clients
|
# Broadcast updated snapshot to all browser clients
|
||||||
await _broadcast_to_browser_clients(snap.dict())
|
await _broadcast_to_browser_clients(snap.dict())
|
||||||
logger.debug(f"✅ Processed telemetry from {snap.character_name} (session: {snap.session_id[:8]}, kills: {snap.kills})")
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to process telemetry event from {data.get('character_name', 'unknown')}: {e}", exc_info=True)
|
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)
|
||||||
continue
|
continue
|
||||||
# --- Rare event: update total and session counters and persist ---
|
# --- Rare event: update total and session counters and persist ---
|
||||||
if msg_type == "rare":
|
if msg_type == "rare":
|
||||||
|
|
@ -1082,6 +1496,9 @@ async def ws_receive_snapshots(
|
||||||
if msg_type:
|
if msg_type:
|
||||||
logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}")
|
logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}")
|
||||||
finally:
|
finally:
|
||||||
|
# Track plugin disconnection
|
||||||
|
_plugin_connections = max(0, _plugin_connections - 1)
|
||||||
|
|
||||||
# Clean up any plugin registrations for this socket
|
# Clean up any plugin registrations for this socket
|
||||||
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
||||||
for n in to_remove:
|
for n in to_remove:
|
||||||
|
|
@ -1153,10 +1570,15 @@ async def ws_live_updates(websocket: WebSocket):
|
||||||
Manages a set of connected browser clients; listens for incoming command messages
|
Manages a set of connected browser clients; listens for incoming command messages
|
||||||
and forwards them to the appropriate plugin client WebSocket.
|
and forwards them to the appropriate plugin client WebSocket.
|
||||||
"""
|
"""
|
||||||
|
global _browser_connections
|
||||||
# Add new browser client to the set
|
# Add new browser client to the set
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
browser_conns.add(websocket)
|
browser_conns.add(websocket)
|
||||||
logger.info(f"Browser WebSocket connected: {websocket.client}")
|
logger.info(f"Browser WebSocket connected: {websocket.client}")
|
||||||
|
|
||||||
|
# Track browser connection
|
||||||
|
_browser_connections += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Receive command messages from browser
|
# Receive command messages from browser
|
||||||
|
|
@ -1198,6 +1620,9 @@ async def ws_live_updates(websocket: WebSocket):
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
# Track browser disconnection
|
||||||
|
_browser_connections = max(0, _browser_connections - 1)
|
||||||
|
|
||||||
browser_conns.discard(websocket)
|
browser_conns.discard(websocket)
|
||||||
logger.debug(f"Removed browser WebSocket from connection pool: {websocket.client}")
|
logger.debug(f"Removed browser WebSocket from connection pool: {websocket.client}")
|
||||||
|
|
||||||
|
|
|
||||||
654
static/debug.html
Normal file
654
static/debug.html
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
<!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,6 +46,20 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 -->
|
<!-- Text input to filter active players by name -->
|
||||||
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
|
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
|
||||||
|
|
||||||
|
|
|
||||||
715
static/inventory.html
Normal file
715
static/inventory.html
Normal file
|
|
@ -0,0 +1,715 @@
|
||||||
|
<!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>
|
||||||
722
static/inventory.js
Normal file
722
static/inventory.js
Normal file
|
|
@ -0,0 +1,722 @@
|
||||||
|
/**
|
||||||
|
* 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,4 +1776,20 @@ function openInventorySearch() {
|
||||||
window.open('/inventory.html', '_blank');
|
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,6 +1240,62 @@ body.noselect, body.noselect * {
|
||||||
margin: -2px -4px;
|
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 column styles for inventory tables */
|
||||||
.sortable {
|
.sortable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue