From a121d57a1300a2b0728167fb36ff992330ff1b36 Mon Sep 17 00:00:00 2001 From: erik Date: Sun, 4 May 2025 14:45:27 +0000 Subject: [PATCH 1/6] new Websockets from client only --- FIXES.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ LESSONSLEARNED.md | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 FIXES.md create mode 100644 LESSONSLEARNED.md diff --git a/FIXES.md b/FIXES.md new file mode 100644 index 00000000..c8160b85 --- /dev/null +++ b/FIXES.md @@ -0,0 +1,48 @@ +# Planned Fixes and Enhancements + +_This document captures the next set of improvements and fixes for Dereth Tracker._ + +## 1. Chat Window Styling and Format +- **Terminal-style chat interface** + - Redesign the chat window to mimic Asheron’s Call in-game chat: monospaced font, dark semi-transparent background, and text entry at the bottom. + - Implement timestamped message prefixes (e.g., `[12:34] character: message`). + - Support command- and system-level styling (e.g., whispers, party chat) with distinct color cues. + +## 2. Incoming Message Parsing +- **Strip protocol overhead** + - Remove JSON envelope artifacts (e.g., remove quotes, braces) so only raw message text appears. + - Validate and sanitize incoming payloads (e.g., escape HTML, truncate length). + - Optionally support rich-text / emotes by parsing simple markup (e.g., `*bold*`, `/me action`). + +## 3. Message Color Scheme +- **Per-character consistent colors** + - Map each character name to a unique, but legible, pastel or muted color. + - Ensure sufficient contrast with the chat background (WCAG AA compliance). + - Provide user override settings for theme (light/dark) and custom palettes. + +## 4. Command Prompt Integration +- **Client-side command entry** + - Allow slash-commands in chat input (e.g., `/kick PlayerName`, `/whisper PlayerName Hello`). + - Validate commands before sending to `/ws/live` and route to the correct plugin WebSocket. + - Show feedback on command success/failure in the chat window. + +## 5. Security Hardening +- **Authentication & Authorization** + - Enforce TLS (HTTPS/WSS) for all HTTP and WebSocket connections. + - Protect `/ws/position` with rotating shared secrets or token-based auth (e.g., JWT). + - Rate-limit incoming telemetry and chat messages to prevent flooding. + - Sanitize all inputs to guard against injection (SQL, XSS) and implement strict CSP headers. + +## 6. Performance and Scalability +- **Throttling and Load Handling** + - Batch updates during high-frequency telemetry bursts to reduce WebSocket churn. + - Cache recent `/live` and `/trails` responses in-memory to relieve SQLite under load. + - Plan for horizontal scaling: stateless FastAPI behind a load balancer with shared database or in-memory pub/sub. + +## 7. Testing and Quality Assurance +- **Automated Tests** + - Unit tests for `db.save_snapshot`, HTTP endpoints, and WebSocket handlers. + - E2E tests for the frontend UI (using Puppeteer or Playwright) to verify chat and map functionality. + - Security regression tests for input sanitization and auth enforcement. + +_Refer to this list when planning next development sprints. Each item should be broken down into individual tickets or pull requests._ \ No newline at end of file diff --git a/LESSONSLEARNED.md b/LESSONSLEARNED.md new file mode 100644 index 00000000..2ee1085d --- /dev/null +++ b/LESSONSLEARNED.md @@ -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] : ` for messages on `/ws/position` + - `[WS-LIVE RX] : ` 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. \ No newline at end of file From 73ae756e5ce5aa3595e8ffd60c248a1f1b1d1311 Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 9 May 2025 22:35:41 +0000 Subject: [PATCH 2/6] ws version with nice DB select --- README.md | 94 +++++++++++++++++++++++----- db.py | 38 ++++++++++- generate_data.py | 72 ++++++++++----------- main.py | 159 ++++++++++++++++++++++++++++++++--------------- static/script.js | 154 ++++++++++++++++++++++++++++++++++++++++++++- static/style.css | 80 ++++++++++++++++++++++++ 6 files changed, 491 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index a4d1aaca..50a7849d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dereth Tracker -Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a 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 - [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) - [Frontend](#frontend) - [Database Schema](#database-schema) -- [Sample Payload](#sample-payload) - [Contributing](#contributing) ## Overview @@ -21,16 +20,16 @@ This project provides: - A FastAPI backend with endpoints for receiving and querying telemetry data. - SQLite-based storage for snapshots and live state. - 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 -- **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 /history**: Retrieve historical telemetry data with optional time filtering. - **GET /debug**: Health check endpoint. - **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 @@ -45,6 +44,7 @@ Python packages: - pydantic - pandas - matplotlib +- websockets # required for sample data generator ## Installation @@ -60,13 +60,14 @@ Python packages: ``` 3. Install dependencies: ```bash - pip install fastapi uvicorn pydantic pandas matplotlib + pip install fastapi uvicorn pydantic pandas matplotlib websockets ``` ## Configuration - 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`. +- To limit the maximum database size, set the environment variable `DB_MAX_SIZE_MB` (default: 2048 MB). ## Usage @@ -76,17 +77,66 @@ Start the server using Uvicorn: uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` -- Live Map: `http://localhost:8000/` -- Analytics Dashboard: `http://localhost:8000/graphs.html` +## NGINX Proxy Configuration + +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:///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] : ` for plugin messages on `/ws/position` + - `[WS-LIVE RX] : ` 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 -### POST /position -Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: `. +### WebSocket /ws/position +Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header: + +``` +ws://:/ws/position?secret= +``` +or +``` +X-Plugin-Secret: +``` + +After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example: -**Request Body Example:** ```json { + "type": "telemetry", "character_name": "Dunking Rares", "char_tag": "moss", "session_id": "dunk-20250422-xyz", @@ -104,6 +154,23 @@ Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: None: """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() + # 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 c.execute( @@ -60,8 +85,17 @@ def init_db() -> None: def save_snapshot(data: Dict) -> None: """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() + # 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 c.execute( diff --git a/generate_data.py b/generate_data.py index fdbbfd1a..d5eb674f 100644 --- a/generate_data.py +++ b/generate_data.py @@ -1,45 +1,45 @@ -import httpx +import asyncio +import websockets +import json from datetime import datetime, timedelta, timezone -from time import sleep from main import TelemetrySnapshot -def main() -> None: +async def main() -> None: wait = 10 online_time = 24 * 3600 # start at 1 day - ew = 0 - ns = 0 - while True: - snapshot = TelemetrySnapshot( - character_name="Test name", - char_tag="test_tag", - session_id="test_session_id", - timestamp=datetime.now(tz=timezone.utc), - ew=ew, - ns=ns, - z=0, - kills=0, - kills_per_hour="kph_str", - onlinetime=str(timedelta(seconds=online_time)), - deaths=0, - rares_found=0, - prismatic_taper_count=0, - vt_state="test state", - ) - resp = httpx.post( - "http://localhost:8000/position/", - data=snapshot.model_dump_json(), - headers={ - "Content-Type": "application/json", - "X-PLUGIN-SECRET": "your_shared_secret", - }, - ) - print(resp) - sleep(wait) - ew += 0.1 - ns += 0.1 - online_time += wait + ew = 0.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: + snapshot = TelemetrySnapshot( + character_name="Test name", + char_tag="test_tag", + session_id="test_session_id", + timestamp=datetime.now(tz=timezone.utc), + ew=ew, + ns=ns, + z=0, + kills=0, + kills_per_hour="kph_str", + onlinetime=str(timedelta(seconds=online_time)), + deaths=0, + rares_found=0, + prismatic_taper_count=0, + vt_state="test state", + ) + # wrap in envelope with message type + payload = snapshot.model_dump() + payload["type"] = "telemetry" + await websocket.send(json.dumps(payload, default=str)) + print(f"Sent snapshot: EW={ew:.2f}, NS={ns:.2f}") + await asyncio.sleep(wait) + ew += 0.1 + ns += 0.1 + online_time += wait if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/main.py b/main.py index fdb48928..05b5c562 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketD from fastapi.responses import JSONResponse from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles +from fastapi.encoders import jsonable_encoder from pydantic import BaseModel from typing import Optional @@ -50,31 +51,6 @@ def on_startup(): 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 ----------------------------------- @app.get("/debug") @@ -82,22 +58,33 @@ def debug(): return {"status": "OK"} -@app.get("/live") -@app.get("/live/") +@app.get("/live", response_model=dict) +@app.get("/live/", response_model=dict) def get_live_players(): - conn = sqlite3.connect(DB_FILE) - conn.row_factory = sqlite3.Row - rows = conn.execute("SELECT * FROM live_state").fetchall() - conn.close() + # compute cutoff once + now_utc = datetime.now(timezone.utc) + cutoff = now_utc - ACTIVE_WINDOW - # aware cutoff (UTC) - cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW + cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S") - players = [ - dict(r) - for r in rows - if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff - ] + try: + with sqlite3.connect(DB_FILE) as conn: + conn.row_factory = sqlite3.Row + query = """ + SELECT * + FROM live_state + WHERE datetime(timestamp) > datetime(?, 'utc') + """ + rows = conn.execute(query, (cutoff_sql,)).fetchall() + + except sqlite3.Error as e: + # log e if you have logging set up + raise HTTPException(status_code=500, detail="Database error") + + # build list of dicts + players = [] + for r in rows: + players.append(dict(r)) return JSONResponse(content={"players": players}) @@ -198,42 +185,114 @@ def get_trails( # -------------------- WebSocket endpoints ----------------------- 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): + # Ensure all data (e.g. datetime) is JSON-serializable + data = jsonable_encoder(snapshot) for ws in list(browser_conns): try: - await ws.send_json(snapshot) + await ws.send_json(data) except WebSocketDisconnect: browser_conns.remove(ws) @app.websocket("/ws/position") -async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)): - await websocket.accept() - if secret != SHARED_SECRET: +async def ws_receive_snapshots( + websocket: WebSocket, + 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) return + # Accept the WebSocket connection + await websocket.accept() + print(f"[WS] Plugin connected: {websocket.client}") try: while True: - data = await websocket.receive_json() - snap = TelemetrySnapshot.parse_obj(data) - live_snapshots[snap.character_name] = snap.dict() - await run_in_threadpool(save_snapshot, snap.dict()) - await _broadcast_to_browser_clients(snap.dict()) - except WebSocketDisconnect: - pass + # Read next text frame + 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() + await run_in_threadpool(save_snapshot, snap.dict()) + await _broadcast_to_browser_clients(snap.dict()) + continue + # 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") async def ws_live_updates(websocket: WebSocket): + # Browser clients connect here to receive telemetry and chat, and send commands await websocket.accept() browser_conns.add(websocket) try: 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: + 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) # -------------------- static frontend --------------------------- +# static frontend app.mount("/", StaticFiles(directory="static", html=True), name="static") # list routes for convenience diff --git a/static/script.js b/static/script.js index bfe94b72..574e22ea 100644 --- a/static/script.js +++ b/static/script.js @@ -8,6 +8,11 @@ const list = document.getElementById('playerList'); const btnContainer = document.getElementById('sortButtons'); 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 ------------------------------------------- */ const MAX_Z = 10; const FOCUS_ZOOM = 3; // zoom level when you click a name @@ -19,6 +24,44 @@ const MAP_BOUNDS = { 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 ---------------------------------- */ const sortOptions = [ { @@ -132,8 +175,8 @@ function hideTooltip() { async function pollLive() { try { const [liveRes, trailsRes] = await Promise.all([ - fetch('/live/'), - fetch('/trails/?seconds=600'), + fetch(`${API_BASE}/live/`), + fetch(`${API_BASE}/trails/?seconds=600`), ]); const { players } = await liveRes.json(); const { trails } = await trailsRes.json(); @@ -162,6 +205,7 @@ img.onload = () => { } fitToWindow(); startPolling(); + initWebSocket(); }; /* ---------- rendering sorted list & dots ------------------------ */ @@ -215,6 +259,15 @@ function render(players) { li.addEventListener('click', () => selectPlayer(p, x, y)); 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); }); } @@ -253,6 +306,103 @@ function selectPlayer(p, x, y) { 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 + chatWindows[name].style.display = 'flex'; + 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; +} + +// 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 -------------------------------- */ wrap.addEventListener('wheel', e => { e.preventDefault(); diff --git a/static/style.css b/static/style.css index b6a7286d..dc8e2e77 100644 --- a/static/style.css +++ b/static/style.css @@ -7,6 +7,11 @@ --text: #eee; --accent: #88f; } +/* Placeholder text in chat input should be white */ +.chat-input::placeholder { + color: #fff; + opacity: 0.7; +} html { margin: 0; @@ -201,6 +206,81 @@ body { background: var(--accent); color: #111; } + +/* ---------- 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; +} + +.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; +} .stat.onlinetime::before { content: "🕑 "} .stat.deaths::before { content: "💀 "} From 337eff56aa8a794bb949ff2b996918f38d28ad44 Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 9 May 2025 23:31:01 +0000 Subject: [PATCH 3/6] added favicon --- main.py | 2 +- static/favicon.ico | Bin 0 -> 6966 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 static/favicon.ico diff --git a/main.py b/main.py index 05b5c562..f5942551 100644 --- a/main.py +++ b/main.py @@ -17,7 +17,7 @@ from starlette.concurrency import run_in_threadpool # ------------------------------------------------------------------ app = FastAPI() - +# test # In-memory store of the last packet per character live_snapshots: Dict[str, dict] = {} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3be83f801e66313c016e388164de24cd1b24bfc4 GIT binary patch literal 6966 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 e9k9bG_J>Z6tANnQ=OKZcJ1xODy#N0%`~Lv+!f Date: Wed, 14 May 2025 09:21:19 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Alex=20ville=20ha=20f=C3=A4rger=20p=C3=A5?= =?UTF-8?q?=20metastate=20f=C3=B6r=20att=20han=20=C3=A4r=20en=20fisk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/script.js | 12 ++++++++++++ static/style.css | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/static/script.js b/static/script.js index 574e22ea..197cb918 100644 --- a/static/script.js +++ b/static/script.js @@ -256,6 +256,18 @@ function render(players) { ${p.onlinetime} ${p.deaths} `; + + // 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)); if (p.character_name === selected) li.classList.add('selected'); diff --git a/static/style.css b/static/style.css index dc8e2e77..d4655eaf 100644 --- a/static/style.css +++ b/static/style.css @@ -202,11 +202,23 @@ body { .stat.kills::before { content: "⚔️ "; } .stat.kph::after { content: " KPH"; 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); 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; From 0313c2a2ae89dcfda41e95775778f69ae4b6fa3b Mon Sep 17 00:00:00 2001 From: erik Date: Thu, 15 May 2025 07:43:17 +0000 Subject: [PATCH 5/6] Added docker file --- Dockerfile | 28 ++++++++++++++++++++++++++++ docker-compose.yml | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..be2c14f1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..013a91f6 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file From d396942deb6292ee7daecc8b0122206d9bf3ad18 Mon Sep 17 00:00:00 2001 From: erik Date: Sun, 18 May 2025 11:14:43 +0000 Subject: [PATCH 6/6] Chat window is now movable --- static/script.js | 79 ++++++++++++++++++++++++++++++++++++++++++++++-- static/style.css | 6 ++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/static/script.js b/static/script.js index 197cb918..499c3855 100644 --- a/static/script.js +++ b/static/script.js @@ -338,8 +338,12 @@ function initWebSocket() { // Display or create a chat window for a character function showChatWindow(name) { if (chatWindows[name]) { - // Restore flex layout when reopening - chatWindows[name].style.display = 'flex'; + // 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'); @@ -380,6 +384,77 @@ function showChatWindow(name) { 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 diff --git a/static/style.css b/static/style.css index d4655eaf..ee615d48 100644 --- a/static/style.css +++ b/static/style.css @@ -253,6 +253,7 @@ body { background: var(--accent); padding: 4px; color: #111; + cursor: move; /* indicates the header is draggable */ } .chat-close-btn { @@ -293,6 +294,11 @@ body { color: #fff; outline: none; } + +/* Prevent text selection while dragging chat windows */ +body.noselect, body.noselect * { + user-select: none !important; +} .stat.onlinetime::before { content: "🕑 "} .stat.deaths::before { content: "💀 "}