Compare commits

..

6 commits

Author SHA1 Message Date
erik
d396942deb Chat window is now movable 2025-05-18 11:14:43 +00:00
erik
0313c2a2ae Added docker file 2025-05-15 07:43:17 +00:00
erik
b94f064118 Alex ville ha färger på metastate för att han är en fisk 2025-05-14 09:21:19 +00:00
erik
337eff56aa added favicon 2025-05-09 23:31:01 +00:00
erik
73ae756e5c ws version with nice DB select 2025-05-09 22:35:41 +00:00
erik
a121d57a13 new Websockets from client only 2025-05-04 14:45:27 +00:00
11 changed files with 734 additions and 108 deletions

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# Dockerfile for Dereth Tracker application
FROM python:3.12-slim
# Set working directory
WORKDIR /app
# Upgrade pip and install Python dependencies
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir fastapi uvicorn pydantic pandas matplotlib websockets
# Copy application code
COPY static/ /app/static/
COPY main.py /app/main.py
COPY db.py /app/db.py
COPY Dockerfile /Dockerfile
# Expose the application port
EXPOSE 8765
# Default environment variables (override as needed)
ENV DB_MAX_SIZE_MB=2048 \
DB_RETENTION_DAYS=7 \
DB_MAX_SQL_LENGTH=1000000000 \
DB_MAX_SQL_VARIABLES=32766 \
DB_WAL_AUTOCHECKPOINT_PAGES=1000 \
SHARED_SECRET=your_shared_secret
# Run the FastAPI application with Uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--reload", "--workers", "1"]

48
FIXES.md Normal file
View file

@ -0,0 +1,48 @@
# Planned Fixes and Enhancements
_This document captures the next set of improvements and fixes for Dereth Tracker._
## 1. Chat Window Styling and Format
- **Terminal-style chat interface**
- Redesign the chat window to mimic Asherons Call in-game chat: monospaced font, dark semi-transparent background, and text entry at the bottom.
- Implement timestamped message prefixes (e.g., `[12:34] character: message`).
- Support command- and system-level styling (e.g., whispers, party chat) with distinct color cues.
## 2. Incoming Message Parsing
- **Strip protocol overhead**
- Remove JSON envelope artifacts (e.g., remove quotes, braces) so only raw message text appears.
- Validate and sanitize incoming payloads (e.g., escape HTML, truncate length).
- Optionally support rich-text / emotes by parsing simple markup (e.g., `*bold*`, `/me action`).
## 3. Message Color Scheme
- **Per-character consistent colors**
- Map each character name to a unique, but legible, pastel or muted color.
- Ensure sufficient contrast with the chat background (WCAG AA compliance).
- Provide user override settings for theme (light/dark) and custom palettes.
## 4. Command Prompt Integration
- **Client-side command entry**
- Allow slash-commands in chat input (e.g., `/kick PlayerName`, `/whisper PlayerName Hello`).
- Validate commands before sending to `/ws/live` and route to the correct plugin WebSocket.
- Show feedback on command success/failure in the chat window.
## 5. Security Hardening
- **Authentication & Authorization**
- Enforce TLS (HTTPS/WSS) for all HTTP and WebSocket connections.
- Protect `/ws/position` with rotating shared secrets or token-based auth (e.g., JWT).
- Rate-limit incoming telemetry and chat messages to prevent flooding.
- Sanitize all inputs to guard against injection (SQL, XSS) and implement strict CSP headers.
## 6. Performance and Scalability
- **Throttling and Load Handling**
- Batch updates during high-frequency telemetry bursts to reduce WebSocket churn.
- Cache recent `/live` and `/trails` responses in-memory to relieve SQLite under load.
- Plan for horizontal scaling: stateless FastAPI behind a load balancer with shared database or in-memory pub/sub.
## 7. Testing and Quality Assurance
- **Automated Tests**
- Unit tests for `db.save_snapshot`, HTTP endpoints, and WebSocket handlers.
- E2E tests for the frontend UI (using Puppeteer or Playwright) to verify chat and map functionality.
- Security regression tests for input sanitization and auth enforcement.
_Refer to this list when planning next development sprints. Each item should be broken down into individual tickets or pull requests._

38
LESSONSLEARNED.md Normal file
View file

@ -0,0 +1,38 @@
# Lessons Learned
_This document captures the key takeaways and implementation details from today's troubleshooting session._
## 1. API Routing & Proxy Configuration
- **API_BASE constant**: The frontend (`static/script.js`) uses a base path `API_BASE` (default `/api`) to prefix all HTTP and WebSocket calls. Always update this to match your proxy mount point.
- **Nginx WebSocket forwarding**: To proxy WebSockets, you must forward the `Upgrade` and `Connection` headers:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
Without these, the WS handshake downgrades to a normal HTTP GET, resulting in 404s.
## 2. Debugging WebSocket Traffic
- Logged all incoming WS frames in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw>` for messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for messages on `/ws/live`
- These prints surface registration, telemetry, chat, and command packets, aiding root-cause analysis.
## 3. Data Serialization Fix
- Python `datetime` objects are not JSON-serializable by default. We wrapped outbound payloads via FastAPIs `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.

View file

@ -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 SQLite database, and provides both a live map interface and an analytics dashboard. Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides a live map interface along with a sample data generator for testing.
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
@ -12,7 +12,6 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
- [API Reference](#api-reference) - [API Reference](#api-reference)
- [Frontend](#frontend) - [Frontend](#frontend)
- [Database Schema](#database-schema) - [Database Schema](#database-schema)
- [Sample Payload](#sample-payload)
- [Contributing](#contributing) - [Contributing](#contributing)
## Overview ## Overview
@ -21,16 +20,16 @@ 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.
- SQLite-based storage for snapshots and live state. - SQLite-based storage for snapshots and live state.
- A live, interactive map using static HTML, CSS, and JavaScript. - A live, interactive map using static HTML, CSS, and JavaScript.
- An analytics dashboard for visualizing kills and session metrics. - A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
## Features ## Features
- **POST /position**: Submit a telemetry snapshot (protected by a shared secret). - **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret).
- **GET /live**: Fetch active players seen in the last 30 seconds. - **GET /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.
- **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js. - **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
## Requirements ## Requirements
@ -45,6 +44,7 @@ Python packages:
- pydantic - pydantic
- pandas - pandas
- matplotlib - matplotlib
- websockets # required for sample data generator
## Installation ## Installation
@ -60,13 +60,14 @@ Python packages:
``` ```
3. Install dependencies: 3. Install dependencies:
```bash ```bash
pip install fastapi uvicorn pydantic pandas matplotlib pip install fastapi uvicorn pydantic pandas matplotlib websockets
``` ```
## Configuration ## Configuration
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`). - Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
- The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`. - The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`.
- To limit the maximum database size, set the environment variable `DB_MAX_SIZE_MB` (default: 2048 MB).
## Usage ## Usage
@ -76,17 +77,66 @@ Start the server using Uvicorn:
uvicorn main:app --reload --host 0.0.0.0 --port 8000 uvicorn main:app --reload --host 0.0.0.0 --port 8000
``` ```
- Live Map: `http://localhost:8000/` ## NGINX Proxy Configuration
- Analytics Dashboard: `http://localhost:8000/graphs.html`
If you cannot reassign the existing `/live` and `/trails` routes, you can namespace this service under `/api` (or any other prefix) and configure NGINX accordingly. Be sure to forward WebSocket upgrade headers so that `/ws/live` and `/ws/position` continue to work. Example:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8765/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
```
Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server.
- Live Map: `http://localhost:8000/` (or `http://<your-domain>/api/` if behind a prefix)
### Frontend Configuration
- In `static/script.js`, the constant `API_BASE` controls where live/trails data and WebSocket `/ws/live` are fetched. By default:
```js
const API_BASE = '/api';
```
Update `API_BASE` if you mount the service under a different path or serve it at root.
### Debugging WebSockets
- Server logs now print every incoming WebSocket frame in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw-payload>` for plugin messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for browser messages on `/ws/live`
- Use these logs to verify messages and troubleshoot handshake failures.
### Styling Adjustments
- Chat input bar is fixed at the bottom of the chat window (`.chat-form { position:absolute; bottom:0; }`).
- Input text and placeholder are white for readability (`.chat-input, .chat-input::placeholder { color:#fff; }`).
- Incoming chat messages forced white via `.chat-messages div { color:#fff !important; }`.
## API Reference ## API Reference
### POST /position ### WebSocket /ws/position
Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`. Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header:
```
ws://<host>:<port>/ws/position?secret=<shared_secret>
```
or
```
X-Plugin-Secret: <shared_secret>
```
After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example:
**Request Body Example:**
```json ```json
{ {
"type": "telemetry",
"character_name": "Dunking Rares", "character_name": "Dunking Rares",
"char_tag": "moss", "char_tag": "moss",
"session_id": "dunk-20250422-xyz", "session_id": "dunk-20250422-xyz",
@ -104,6 +154,23 @@ Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secr
} }
``` ```
### Chat messages
You can also send chat envelopes over the same WebSocket to display messages in the browser. Fields:
- `type`: must be "chat"
- `character_name`: target player name
- `text`: message content
- `color` (optional): CSS color string (e.g. "#ff8800"); if sent as an integer (0xRRGGBB), it will be converted to hex.
Example chat payload:
```json
{
"type": "chat",
"character_name": "MyCharacter",
"text": "Hello world!",
"color": "#88f"
}
```
### GET /live ### GET /live
Returns active players seen within the last 30 seconds: Returns active players seen within the last 30 seconds:
@ -131,17 +198,12 @@ 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.
- **Analytics Dashboard**: `static/graphs.html` Interactive charts powered by [D3.js](https://d3js.org/).
## Database Schema ## Database Schema
- **telemetry_log**: Stored history of snapshots. - **telemetry_log**: Stored history of snapshots.
- **live_state**: Current snapshot per character (upserted). - **live_state**: Current snapshot per character (upserted).
## Sample Payload
See `test.json` for an example telemetry snapshot.
## 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.

38
db.py
View file

@ -1,13 +1,38 @@
import os
import sqlite3 import sqlite3
from typing import Dict from typing import Dict
from datetime import datetime, timedelta
DB_FILE = "dereth.db" DB_FILE = "dereth.db"
# Maximum allowed database size (in MB). Defaults to 2048 (2GB). Override via env DB_MAX_SIZE_MB.
MAX_DB_SIZE_MB = int(os.getenv("DB_MAX_SIZE_MB", "2048"))
# Retention window for telemetry history in days. Override via env DB_RETENTION_DAYS.
MAX_RETENTION_DAYS = int(os.getenv("DB_RETENTION_DAYS", "7"))
# SQLite runtime limits customization
DB_MAX_SQL_LENGTH = int(os.getenv("DB_MAX_SQL_LENGTH", "1000000000"))
DB_MAX_SQL_VARIABLES = int(os.getenv("DB_MAX_SQL_VARIABLES", "32766"))
# Number of WAL frames to write before forcing a checkpoint (override via env DB_WAL_AUTOCHECKPOINT_PAGES)
DB_WAL_AUTOCHECKPOINT_PAGES = int(os.getenv("DB_WAL_AUTOCHECKPOINT_PAGES", "1000"))
def init_db() -> None: def init_db() -> None:
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime).""" """Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
conn = sqlite3.connect(DB_FILE) # Open connection with a longer timeout
conn = sqlite3.connect(DB_FILE, timeout=30)
# Bump SQLite runtime limits
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
c = conn.cursor() c = conn.cursor()
# Enable auto_vacuum FULL and rebuild DB so that deletions shrink the file
c.execute("PRAGMA auto_vacuum=FULL;")
conn.commit()
c.execute("VACUUM;")
conn.commit()
# Switch to WAL mode for concurrency, adjust checkpointing, and enforce max size
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
# History log # History log
c.execute( c.execute(
@ -60,8 +85,17 @@ def init_db() -> None:
def save_snapshot(data: Dict) -> None: def save_snapshot(data: Dict) -> None:
"""Insert snapshot into history and upsert into live_state (with new fields).""" """Insert snapshot into history and upsert into live_state (with new fields)."""
conn = sqlite3.connect(DB_FILE) # Open connection with a longer busy timeout
conn = sqlite3.connect(DB_FILE, timeout=30)
# Bump SQLite runtime limits on this connection
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
c = conn.cursor() c = conn.cursor()
# Ensure WAL mode, checkpointing, and size limit on this connection
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
# Insert full history row # Insert full history row
c.execute( c.execute(

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
version: "3.8"
services:
dereth-tracker:
build: .
ports:
- "127.0.0.1:8765:8765"
volumes:
# Mount local database file for persistence
- "./dereth.db:/app/dereth.db"
- "./main.py:/app/main.py"
- "./db.py:/app/db.py"
- "./static:/app/static"
environment:
# Override database and application settings as needed
DB_MAX_SIZE_MB: "2048"
DB_RETENTION_DAYS: "7"
DB_MAX_SQL_LENGTH: "1000000000"
DB_MAX_SQL_VARIABLES: "32766"
DB_WAL_AUTOCHECKPOINT_PAGES: "1000"
SHARED_SECRET: "your_shared_secret"
restart: unless-stopped

View file

@ -1,14 +1,18 @@
import httpx import asyncio
import websockets
import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from time import sleep
from main import TelemetrySnapshot from main import TelemetrySnapshot
def main() -> None: async def main() -> None:
wait = 10 wait = 10
online_time = 24 * 3600 # start at 1 day online_time = 24 * 3600 # start at 1 day
ew = 0 ew = 0.0
ns = 0 ns = 0.0
uri = "ws://localhost:8000/ws/position?secret=your_shared_secret"
async with websockets.connect(uri) as websocket:
print(f"Connected to {uri}")
while True: while True:
snapshot = TelemetrySnapshot( snapshot = TelemetrySnapshot(
character_name="Test name", character_name="Test name",
@ -26,20 +30,16 @@ def main() -> None:
prismatic_taper_count=0, prismatic_taper_count=0,
vt_state="test state", vt_state="test state",
) )
resp = httpx.post( # wrap in envelope with message type
"http://localhost:8000/position/", payload = snapshot.model_dump()
data=snapshot.model_dump_json(), payload["type"] = "telemetry"
headers={ await websocket.send(json.dumps(payload, default=str))
"Content-Type": "application/json", print(f"Sent snapshot: EW={ew:.2f}, NS={ns:.2f}")
"X-PLUGIN-SECRET": "your_shared_secret", await asyncio.sleep(wait)
},
)
print(resp)
sleep(wait)
ew += 0.1 ew += 0.1
ns += 0.1 ns += 0.1
online_time += wait online_time += wait
if __name__ == "__main__": if __name__ == "__main__":
main() asyncio.run(main())

153
main.py
View file

@ -7,6 +7,7 @@ from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketD
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@ -16,7 +17,7 @@ from starlette.concurrency import run_in_threadpool
# ------------------------------------------------------------------ # ------------------------------------------------------------------
app = FastAPI() app = FastAPI()
# test
# In-memory store of the last packet per character # In-memory store of the last packet per character
live_snapshots: Dict[str, dict] = {} live_snapshots: Dict[str, dict] = {}
@ -50,31 +51,6 @@ def on_startup():
init_db() init_db()
# ------------------------ POST ----------------------------------
@app.post("/position")
@app.post("/position/")
async def receive_snapshot(
snapshot: TelemetrySnapshot, x_plugin_secret: str = Header(None)
):
if x_plugin_secret != SHARED_SECRET:
raise HTTPException(status_code=401, detail="Unauthorized")
# cache for /live
live_snapshots[snapshot.character_name] = snapshot.dict()
# save in sqlite
save_snapshot(snapshot.dict())
# optional log-file append
# with open(LOG_FILE, "a") as f:
# f.write(json.dumps(snapshot.dict(), default=str) + "\n")
print(
f"[{datetime.now()}] {snapshot.character_name} @ NS={snapshot.ns:+.2f}, EW={snapshot.ew:+.2f}"
)
return {"status": "ok"}
# ------------------------ GET ----------------------------------- # ------------------------ GET -----------------------------------
@app.get("/debug") @app.get("/debug")
@ -82,22 +58,33 @@ def debug():
return {"status": "OK"} return {"status": "OK"}
@app.get("/live") @app.get("/live", response_model=dict)
@app.get("/live/") @app.get("/live/", response_model=dict)
def get_live_players(): def get_live_players():
conn = sqlite3.connect(DB_FILE) # compute cutoff once
now_utc = datetime.now(timezone.utc)
cutoff = now_utc - ACTIVE_WINDOW
cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S")
try:
with sqlite3.connect(DB_FILE) as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT * FROM live_state").fetchall() query = """
conn.close() SELECT *
FROM live_state
WHERE datetime(timestamp) > datetime(?, 'utc')
"""
rows = conn.execute(query, (cutoff_sql,)).fetchall()
# aware cutoff (UTC) except sqlite3.Error as e:
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW # log e if you have logging set up
raise HTTPException(status_code=500, detail="Database error")
players = [ # build list of dicts
dict(r) players = []
for r in rows for r in rows:
if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff players.append(dict(r))
]
return JSONResponse(content={"players": players}) return JSONResponse(content={"players": players})
@ -198,42 +185,114 @@ def get_trails(
# -------------------- WebSocket endpoints ----------------------- # -------------------- WebSocket endpoints -----------------------
browser_conns: set[WebSocket] = set() browser_conns: set[WebSocket] = set()
# Map of registered plugin clients: character_name -> WebSocket
plugin_conns: Dict[str, WebSocket] = {}
async def _broadcast_to_browser_clients(snapshot: dict): async def _broadcast_to_browser_clients(snapshot: dict):
# Ensure all data (e.g. datetime) is JSON-serializable
data = jsonable_encoder(snapshot)
for ws in list(browser_conns): for ws in list(browser_conns):
try: try:
await ws.send_json(snapshot) await ws.send_json(data)
except WebSocketDisconnect: except WebSocketDisconnect:
browser_conns.remove(ws) browser_conns.remove(ws)
@app.websocket("/ws/position") @app.websocket("/ws/position")
async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)): async def ws_receive_snapshots(
await websocket.accept() websocket: WebSocket,
if secret != SHARED_SECRET: secret: str | None = Query(None),
x_plugin_secret: str | None = Header(None)
):
# Verify shared secret from query parameter or header
key = secret or x_plugin_secret
if key != SHARED_SECRET:
# Reject without completing the WebSocket handshake
await websocket.close(code=1008) await websocket.close(code=1008)
return return
# Accept the WebSocket connection
await websocket.accept()
print(f"[WS] Plugin connected: {websocket.client}")
try: try:
while True: while True:
data = await websocket.receive_json() # Read next text frame
snap = TelemetrySnapshot.parse_obj(data) try:
raw = await websocket.receive_text()
# Debug: log all incoming plugin WebSocket messages
print(f"[WS-PLUGIN RX] {websocket.client}: {raw}")
except WebSocketDisconnect:
print(f"[WS] Plugin disconnected: {websocket.client}")
break
# Parse JSON payload
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = data.get("type")
# Registration message: map character to this socket
if msg_type == "register":
name = data.get("character_name") or data.get("player_name")
if isinstance(name, str):
plugin_conns[name] = websocket
continue
# Telemetry message: save to DB and broadcast
if msg_type == "telemetry":
payload = data.copy()
payload.pop("type", None)
snap = TelemetrySnapshot.parse_obj(payload)
live_snapshots[snap.character_name] = snap.dict() live_snapshots[snap.character_name] = snap.dict()
await run_in_threadpool(save_snapshot, snap.dict()) await run_in_threadpool(save_snapshot, snap.dict())
await _broadcast_to_browser_clients(snap.dict()) await _broadcast_to_browser_clients(snap.dict())
except WebSocketDisconnect: continue
pass # Chat message: broadcast to browser clients only (no DB write)
if msg_type == "chat":
await _broadcast_to_browser_clients(data)
continue
# Unknown message types are ignored
finally:
# Clean up any plugin registrations for this socket
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
for n in to_remove:
del plugin_conns[n]
print(f"[WS] Cleaned up plugin connections for {websocket.client}")
@app.websocket("/ws/live") @app.websocket("/ws/live")
async def ws_live_updates(websocket: WebSocket): async def ws_live_updates(websocket: WebSocket):
# Browser clients connect here to receive telemetry and chat, and send commands
await websocket.accept() await websocket.accept()
browser_conns.add(websocket) browser_conns.add(websocket)
try: try:
while True: while True:
await asyncio.sleep(3600) # Receive command messages from browser
try:
data = await websocket.receive_json()
# Debug: log all incoming browser WebSocket messages
print(f"[WS-LIVE RX] {websocket.client}: {data}")
except WebSocketDisconnect: except WebSocketDisconnect:
break
# Determine command envelope format (new or legacy)
if "player_name" in data and "command" in data:
# New format: { player_name, command }
target_name = data["player_name"]
payload = data
elif data.get("type") == "command" and "character_name" in data and "text" in data:
# Legacy format: { type: 'command', character_name, text }
target_name = data.get("character_name")
payload = {"player_name": target_name, "command": data.get("text")}
else:
# Not a recognized command envelope
continue
# Forward command envelope to the appropriate plugin WebSocket
target_ws = plugin_conns.get(target_name)
if target_ws:
await target_ws.send_json(payload)
except WebSocketDisconnect:
pass
finally:
browser_conns.remove(websocket) browser_conns.remove(websocket)
# -------------------- static frontend --------------------------- # -------------------- static frontend ---------------------------
# static frontend
app.mount("/", StaticFiles(directory="static", html=True), name="static") app.mount("/", StaticFiles(directory="static", html=True), name="static")
# list routes for convenience # list routes for convenience

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -8,6 +8,11 @@ const list = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons'); const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip'); const tooltip = document.getElementById('tooltip');
// WebSocket for chat and commands
let socket;
// Keep track of open chat windows: character_name -> DOM element
const chatWindows = {};
/* ---------- constants ------------------------------------------- */ /* ---------- constants ------------------------------------------- */
const MAX_Z = 10; const MAX_Z = 10;
const FOCUS_ZOOM = 3; // zoom level when you click a name const FOCUS_ZOOM = 3; // zoom level when you click a name
@ -19,6 +24,44 @@ const MAP_BOUNDS = {
south: -102.00 south: -102.00
}; };
// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
const API_BASE = '/api';
// Maximum number of lines to retain in each chat window scrollback
const MAX_CHAT_LINES = 1000;
// Map numeric chat color codes to CSS hex colors
const CHAT_COLOR_MAP = {
0: '#00FF00', // Broadcast
2: '#FFFFFF', // Speech
3: '#FFD700', // Tell
4: '#CCCC00', // OutgoingTell
5: '#FF00FF', // System
6: '#FF0000', // Combat
7: '#00CCFF', // Magic
8: '#DDDDDD', // Channel
9: '#FF9999', // ChannelSend
10: '#FFFF33', // Social
11: '#CCFF33', // SocialSend
12: '#FFFFFF', // Emote
13: '#00FFFF', // Advancement
14: '#66CCFF', // Abuse
15: '#FF0000', // Help
16: '#33FF00', // Appraisal
17: '#0099FF', // Spellcasting
18: '#FF6600', // Allegiance
19: '#CC66FF', // Fellowship
20: '#00FF00', // WorldBroadcast
21: '#FF0000', // CombatEnemy
22: '#FF33CC', // CombatSelf
23: '#00CC00', // Recall
24: '#00FF00', // Craft
25: '#00FF66', // Salvaging
27: '#FFFFFF', // General
28: '#33FF33', // Trade
29: '#CCCCCC', // LFG
30: '#CC00CC', // Roleplay
31: '#FFFF00' // AdminTell
};
/* ---------- sort configuration ---------------------------------- */ /* ---------- sort configuration ---------------------------------- */
const sortOptions = [ const sortOptions = [
{ {
@ -132,8 +175,8 @@ function hideTooltip() {
async function pollLive() { async function pollLive() {
try { try {
const [liveRes, trailsRes] = await Promise.all([ const [liveRes, trailsRes] = await Promise.all([
fetch('/live/'), fetch(`${API_BASE}/live/`),
fetch('/trails/?seconds=600'), fetch(`${API_BASE}/trails/?seconds=600`),
]); ]);
const { players } = await liveRes.json(); const { players } = await liveRes.json();
const { trails } = await trailsRes.json(); const { trails } = await trailsRes.json();
@ -162,6 +205,7 @@ img.onload = () => {
} }
fitToWindow(); fitToWindow();
startPolling(); startPolling();
initWebSocket();
}; };
/* ---------- rendering sorted list & dots ------------------------ */ /* ---------- rendering sorted list & dots ------------------------ */
@ -213,8 +257,29 @@ function render(players) {
<span class="stat deaths">${p.deaths}</span> <span class="stat deaths">${p.deaths}</span>
`; `;
// Color the metastate pill according to its value
const metaSpan = li.querySelector('.stat.meta');
if (metaSpan) {
const goodStates = ['default', 'default2', 'hunt', 'combat'];
const state = (p.vt_state || '').toString().toLowerCase();
if (goodStates.includes(state)) {
metaSpan.classList.add('green');
} else {
metaSpan.classList.add('red');
}
}
li.addEventListener('click', () => selectPlayer(p, x, y)); li.addEventListener('click', () => selectPlayer(p, x, y));
if (p.character_name === selected) li.classList.add('selected'); if (p.character_name === selected) li.classList.add('selected');
// Chat button
const chatBtn = document.createElement('button');
chatBtn.className = 'chat-btn';
chatBtn.textContent = 'Chat';
chatBtn.addEventListener('click', e => {
e.stopPropagation();
showChatWindow(p.character_name);
});
li.appendChild(chatBtn);
list.appendChild(li); list.appendChild(li);
}); });
} }
@ -253,6 +318,178 @@ function selectPlayer(p, x, y) {
renderList(); // keep sorted + highlight renderList(); // keep sorted + highlight
} }
/* ---------- chat & command handlers ---------------------------- */
// Initialize WebSocket for chat and commands
function initWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
socket = new WebSocket(wsUrl);
socket.addEventListener('message', evt => {
let msg;
try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'chat') {
appendChatMessage(msg);
}
});
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
socket.addEventListener('error', e => console.error('WebSocket error:', e));
}
// Display or create a chat window for a character
function showChatWindow(name) {
if (chatWindows[name]) {
// Restore flex layout when reopening & bring to front
const existing = chatWindows[name];
existing.style.display = 'flex';
if (!window.__chatZ) window.__chatZ = 10000;
window.__chatZ += 1;
existing.style.zIndex = window.__chatZ;
return;
}
const win = document.createElement('div');
win.className = 'chat-window';
win.dataset.character = name;
// Header
const header = document.createElement('div');
header.className = 'chat-header';
const title = document.createElement('span');
title.textContent = `Chat: ${name}`;
const closeBtn = document.createElement('button');
closeBtn.className = 'chat-close-btn';
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
header.appendChild(title);
header.appendChild(closeBtn);
win.appendChild(header);
// Messages container
const msgs = document.createElement('div');
msgs.className = 'chat-messages';
win.appendChild(msgs);
// Input form
const form = document.createElement('form');
form.className = 'chat-form';
const input = document.createElement('input');
input.type = 'text';
input.className = 'chat-input';
input.placeholder = 'Enter chat...';
form.appendChild(input);
form.addEventListener('submit', e => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
// Send command envelope: player_name and command only
socket.send(JSON.stringify({ player_name: name, command: text }));
input.value = '';
});
win.appendChild(form);
document.body.appendChild(win);
chatWindows[name] = win;
/* --------------------------------------------------------- */
/* enable dragging of the chat window via its header element */
/* --------------------------------------------------------- */
// keep a static counter so newer windows can be brought to front
if (!window.__chatZ) window.__chatZ = 10000;
let drag = false;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
header.style.cursor = 'move';
// bring to front when interacting
const bringToFront = () => {
window.__chatZ += 1;
win.style.zIndex = window.__chatZ;
};
header.addEventListener('mousedown', e => {
// don't initiate drag when pressing the close button (or other clickable controls)
if (e.target.closest('button')) return;
e.preventDefault();
drag = true;
bringToFront();
startX = e.clientX;
startY = e.clientY;
// current absolute position
startLeft = win.offsetLeft;
startTop = win.offsetTop;
document.body.classList.add('noselect');
});
window.addEventListener('mousemove', e => {
if (!drag) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('mouseup', () => {
drag = false;
document.body.classList.remove('noselect');
});
/* touch support */
header.addEventListener('touchstart', e => {
if (e.touches.length !== 1 || e.target.closest('button')) return;
drag = true;
bringToFront();
const t = e.touches[0];
startX = t.clientX;
startY = t.clientY;
startLeft = win.offsetLeft;
startTop = win.offsetTop;
});
window.addEventListener('touchmove', e => {
if (!drag || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('touchend', () => {
drag = false;
});
}
// Append a chat message to the correct window
/**
* Append a chat message to the correct window, optionally coloring the text.
* msg: { type: 'chat', character_name, text, color? }
*/
function appendChatMessage(msg) {
const { character_name: name, text, color } = msg;
const win = chatWindows[name];
if (!win) return;
const msgs = win.querySelector('.chat-messages');
const p = document.createElement('div');
if (color !== undefined) {
let c = color;
if (typeof c === 'number') {
// map numeric chat code to configured color, or fallback to raw hex
if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
c = CHAT_COLOR_MAP[c];
} else {
c = '#' + c.toString(16).padStart(6, '0');
}
}
p.style.color = c;
}
p.textContent = text;
msgs.appendChild(p);
// Enforce max number of lines in scrollback
while (msgs.children.length > MAX_CHAT_LINES) {
msgs.removeChild(msgs.firstChild);
}
// Scroll to bottom
msgs.scrollTop = msgs.scrollHeight;
}
/* ---------- pan & zoom handlers -------------------------------- */ /* ---------- pan & zoom handlers -------------------------------- */
wrap.addEventListener('wheel', e => { wrap.addEventListener('wheel', e => {
e.preventDefault(); e.preventDefault();

View file

@ -7,6 +7,11 @@
--text: #eee; --text: #eee;
--accent: #88f; --accent: #88f;
} }
/* Placeholder text in chat input should be white */
.chat-input::placeholder {
color: #fff;
opacity: 0.7;
}
html { html {
margin: 0; margin: 0;
@ -197,10 +202,103 @@ body {
.stat.kills::before { content: "⚔️ "; } .stat.kills::before { content: "⚔️ "; }
.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; } .stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; } .stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
.stat.meta { /* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */
#playerList li .stat.meta {
/* fallback */
background: var(--accent); background: var(--accent);
color: #111; color: #111;
} }
#playerList li .stat.meta.green {
background: #2ecc71; /* pleasant green */
color: #111;
}
#playerList li .stat.meta.red {
background: #e74c3c; /* vivid red */
color: #fff;
}
/* ---------- chat window styling ------------------------------- */
.chat-btn {
margin-top: 4px;
padding: 2px 6px;
background: var(--accent);
color: #111;
border: none;
border-radius: 3px;
font-size: 0.75rem;
cursor: pointer;
}
.chat-window {
position: absolute;
top: 10px;
/* position window to start just right of the sidebar */
left: calc(var(--sidebar-width) + 10px);
/* increase default size for better usability */
width: 760px; /* increased width for larger terminal area */
height: 300px;
background: var(--card);
border: 1px solid #555;
display: flex;
flex-direction: column;
z-index: 10000;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--accent);
padding: 4px;
color: #111;
cursor: move; /* indicates the header is draggable */
}
.chat-close-btn {
background: transparent;
border: none;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 4px;
font-size: 0.85rem;
color: #fff;
/* reserve space so messages aren't hidden behind the input */
padding-bottom: 40px;
}
.chat-form {
display: flex;
border-top: 1px solid #333;
/* fix input area to the bottom of the chat window */
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #333;
z-index: 10;
}
.chat-input {
flex: 1;
padding: 4px 6px;
border: none;
background: #333;
color: #fff;
outline: none;
}
/* Prevent text selection while dragging chat windows */
body.noselect, body.noselect * {
user-select: none !important;
}
.stat.onlinetime::before { content: "🕑 "} .stat.onlinetime::before { content: "🕑 "}
.stat.deaths::before { content: "💀 "} .stat.deaths::before { content: "💀 "}