diff --git a/README.md b/README.md index 9044d362..32d41745 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,18 @@ This project provides: ## Requirements -- Python 3.9 or newer -- pip -- (Optional) virtual environment tool (venv) +- Python 3.9 or newer (only if running without Docker) +- pip (only if running without Docker) +- Docker & Docker Compose (recommended) -Python packages: +Python packages (if using local virtualenv): - fastapi - uvicorn - pydantic +- databases +- asyncpg +- sqlalchemy - websockets # required for sample data generator ## Installation @@ -74,6 +77,22 @@ Start the server using Uvicorn: ```bash uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` + +# Grafana Dashboard UI +```nginx +location /grafana/ { + proxy_pass http://127.0.0.1:3000/; + 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 (for live panels) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_cache_bypass $http_upgrade; +} +``` ## NGINX Proxy Configuration @@ -94,7 +113,8 @@ location /api/ { ``` 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) + - Live Map: `http://localhost:8000/` (or `http:///api/` if behind a prefix) + - Grafana UI: `http://localhost:3000/grafana/` (or `http:///grafana/` if proxied under that path) ### Frontend Configuration diff --git a/db_async.py b/db_async.py index 89ce0ca0..81b69787 100644 --- a/db_async.py +++ b/db_async.py @@ -93,11 +93,15 @@ async def init_db_async(): # Enable TimescaleDB extension and convert telemetry_events to hypertable # Use a transactional context to ensure DDL statements are committed with engine.begin() as conn: - # Enable TimescaleDB extension (may already exist) + # Enable or update TimescaleDB extension try: conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) except Exception as e: print(f"Warning: failed to create extension timescaledb: {e}") + try: + conn.execute(text("ALTER EXTENSION timescaledb UPDATE")) + except Exception as e: + print(f"Warning: failed to update timescaledb extension: {e}") # Create hypertable for telemetry_events, skip default indexes to avoid collisions try: conn.execute(text( diff --git a/docker-compose.yml b/docker-compose.yml index 01f7ff93..a6cb6081 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,16 +14,21 @@ services: - "./alembic:/app/alembic" - "./alembic.ini:/app/alembic.ini" environment: - # Database connection URL for TimescaleDB - DATABASE_URL: "postgresql://postgres:password@db:5432/dereth" + # Database connection URL for TimescaleDB (built from POSTGRES_PASSWORD) + DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth" # Override 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" + DB_MAX_SIZE_MB: "${DB_MAX_SIZE_MB}" + DB_RETENTION_DAYS: "${DB_RETENTION_DAYS}" + DB_MAX_SQL_LENGTH: "${DB_MAX_SQL_LENGTH}" + DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}" + DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}" + SHARED_SECRET: "${SHARED_SECRET}" restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" db: image: timescale/timescaledb:latest-pg14 @@ -31,11 +36,44 @@ services: environment: POSTGRES_DB: dereth POSTGRES_USER: postgres - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" volumes: - timescale-data:/var/lib/postgresql/data ports: - "5432:5432" + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + grafana: + image: grafana/grafana:latest + container_name: dereth-grafana + ports: + - "127.0.0.1:3000:3000" + depends_on: + - db + environment: + # Grafana admin settings + GF_SECURITY_ADMIN_PASSWORD: "${GF_SECURITY_ADMIN_PASSWORD}" + # Allow embedding Grafana dashboards in iframes + GF_SECURITY_ALLOW_EMBEDDING: "true" + GF_USERS_ALLOW_SIGN_UP: "false" + # Serve Grafana under /grafana path + GF_SERVER_ROOT_URL: "https://overlord.snakedesert.se/grafana" + GF_SERVER_SERVE_FROM_SUB_PATH: "true" + # Postgres password for provisioning datasource + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + volumes: + # Provisioning directories for automated data source and dashboards + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" volumes: timescale-data: \ No newline at end of file diff --git a/main.py b/main.py index 753d9347..decfe747 100644 --- a/main.py +++ b/main.py @@ -381,12 +381,44 @@ async def ws_live_updates(websocket: WebSocket): browser_conns.remove(websocket) -# -------------------- static frontend --------------------------- -# static frontend -app.mount("/", StaticFiles(directory="static", html=True), name="static") +## -------------------- static frontend --------------------------- +## (static mount moved to end of file, below API routes) # list routes for convenience print("🔍 Registered routes:") for route in app.routes: if isinstance(route, APIRoute): print(f"{route.path} -> {route.methods}") + # Add stats endpoint for per-character metrics +@app.get("/stats/{character_name}") +async def get_stats(character_name: str): + """Return latest telemetry snapshot and aggregates for a specific character.""" + # Latest snapshot + sql_snap = ( + "SELECT * FROM telemetry_events " + "WHERE character_name = :cn " + "ORDER BY timestamp DESC LIMIT 1" + ) + snap = await database.fetch_one(sql_snap, {"cn": character_name}) + if not snap: + raise HTTPException(status_code=404, detail="Character not found") + snap_dict = dict(snap) + # Total kills + sql_kills = "SELECT total_kills FROM char_stats WHERE character_name = :cn" + row_kills = await database.fetch_one(sql_kills, {"cn": character_name}) + total_kills = row_kills["total_kills"] if row_kills else 0 + # Total rares + sql_rares = "SELECT total_rares FROM rare_stats WHERE character_name = :cn" + row_rares = await database.fetch_one(sql_rares, {"cn": character_name}) + total_rares = row_rares["total_rares"] if row_rares else 0 + result = { + "character_name": character_name, + "latest_snapshot": snap_dict, + "total_kills": total_kills, + "total_rares": total_rares, + } + return JSONResponse(content=jsonable_encoder(result)) + +# -------------------- static frontend --------------------------- +# Serve SPA files (catch-all for frontend routes) +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/static/script.js b/static/script.js index e158f4bf..fba83502 100644 --- a/static/script.js +++ b/static/script.js @@ -12,6 +12,8 @@ const tooltip = document.getElementById('tooltip'); let socket; // Keep track of open chat windows: character_name -> DOM element const chatWindows = {}; +// Keep track of open stats windows: character_name -> DOM element +const statsWindows = {}; /* ---------- constants ------------------------------------------- */ const MAX_Z = 10; @@ -25,7 +27,8 @@ const MAP_BOUNDS = { }; // Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy -const API_BASE = '/api'; +// If serving APIs at root, leave empty +const API_BASE = ''; // 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 @@ -134,6 +137,59 @@ function worldToPx(ew, ns) { return { x, y }; } +// Show or create a stats window for a character +function showStatsWindow(name) { + if (statsWindows[name]) { + const existing = statsWindows[name]; + existing.style.display = 'flex'; + return; + } + const win = document.createElement('div'); + win.className = 'stats-window'; + win.dataset.character = name; + // Header (reuses chat-header styling) + const header = document.createElement('div'); + header.className = 'chat-header'; + const title = document.createElement('span'); + title.textContent = `Stats: ${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); + // Content container + const content = document.createElement('div'); + content.className = 'chat-messages'; + content.textContent = 'Loading stats...'; + win.appendChild(content); + document.body.appendChild(win); + statsWindows[name] = win; + // Embed a 2×2 grid of Grafana solo-panel iframes for this character + content.innerHTML = ''; + const panels = [ + { title: 'Kills per Hour', id: 1 }, + { title: 'Memory (MB)', id: 2 }, + { title: 'CPU (%)', id: 3 }, + { title: 'Mem Handles', id: 4 } + ]; + panels.forEach(p => { + const iframe = document.createElement('iframe'); + iframe.src = + `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` + + `?panelId=${p.id}` + + `&var-character=${encodeURIComponent(name)}` + + `&theme=light`; + iframe.setAttribute('title', p.title); + iframe.width = '350'; + iframe.height = '200'; + iframe.frameBorder = '0'; + iframe.allowFullscreen = true; + content.appendChild(iframe); + }); +} + const applyTransform = () => group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; @@ -280,6 +336,15 @@ function render(players) { showChatWindow(p.character_name); }); li.appendChild(chatBtn); + // Stats button + const statsBtn = document.createElement('button'); + statsBtn.className = 'stats-btn'; + statsBtn.textContent = 'Stats'; + statsBtn.addEventListener('click', e => { + e.stopPropagation(); + showStatsWindow(p.character_name); + }); + li.appendChild(statsBtn); list.appendChild(li); }); } diff --git a/static/style.css b/static/style.css index ee615d48..aad700bd 100644 --- a/static/style.css +++ b/static/style.css @@ -220,7 +220,7 @@ body { } /* ---------- chat window styling ------------------------------- */ -.chat-btn { +.chat-btn, .stats-btn { margin-top: 4px; padding: 2px 6px; background: var(--accent); @@ -231,7 +231,7 @@ body { cursor: pointer; } -.chat-window { +.chat-window, .stats-window { position: absolute; top: 10px; /* position window to start just right of the sidebar */ @@ -319,3 +319,24 @@ body.noselect, body.noselect * { stroke-linecap: round; stroke-linejoin: round; } +/* -------------------------------------------------------- */ +/* Stats window: 2×2 iframe grid and flexible height */ +.stats-window { + /* allow height to expand to fit two rows of panels */ + height: auto; +} +.stats-window .chat-messages { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-auto-rows: auto; + gap: 10px; + padding: 10px; + overflow: visible; + background: #f7f7f7; + color: #000; +} +.stats-window iframe { + width: 350px; + height: 200px; + border: none; +}