New version with grafana

This commit is contained in:
erik 2025-05-23 08:11:11 +00:00
parent f86ad9a542
commit b2f649a489
6 changed files with 201 additions and 21 deletions

View file

@ -33,15 +33,18 @@ This project provides:
## Requirements ## Requirements
- Python 3.9 or newer - Python 3.9 or newer (only if running without Docker)
- pip - pip (only if running without Docker)
- (Optional) virtual environment tool (venv) - Docker & Docker Compose (recommended)
Python packages: Python packages (if using local virtualenv):
- fastapi - fastapi
- uvicorn - uvicorn
- pydantic - pydantic
- databases
- asyncpg
- sqlalchemy
- websockets # required for sample data generator - websockets # required for sample data generator
## Installation ## Installation
@ -75,6 +78,22 @@ 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
``` ```
# 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 ## 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: 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:
@ -95,6 +114,7 @@ location /api/ {
Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server. 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) - Live Map: `http://localhost:8000/` (or `http://<your-domain>/api/` if behind a prefix)
- Grafana UI: `http://localhost:3000/grafana/` (or `http://<your-domain>/grafana/` if proxied under that path)
### Frontend Configuration ### Frontend Configuration

View file

@ -93,11 +93,15 @@ async def init_db_async():
# Enable TimescaleDB extension and convert telemetry_events to hypertable # Enable TimescaleDB extension and convert telemetry_events to hypertable
# Use a transactional context to ensure DDL statements are committed # Use a transactional context to ensure DDL statements are committed
with engine.begin() as conn: with engine.begin() as conn:
# Enable TimescaleDB extension (may already exist) # Enable or update TimescaleDB extension
try: try:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb"))
except Exception as e: except Exception as e:
print(f"Warning: failed to create extension timescaledb: {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 # Create hypertable for telemetry_events, skip default indexes to avoid collisions
try: try:
conn.execute(text( conn.execute(text(

View file

@ -14,16 +14,21 @@ services:
- "./alembic:/app/alembic" - "./alembic:/app/alembic"
- "./alembic.ini:/app/alembic.ini" - "./alembic.ini:/app/alembic.ini"
environment: environment:
# Database connection URL for TimescaleDB # Database connection URL for TimescaleDB (built from POSTGRES_PASSWORD)
DATABASE_URL: "postgresql://postgres:password@db:5432/dereth" DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth"
# Override application settings as needed # Override application settings as needed
DB_MAX_SIZE_MB: "2048" DB_MAX_SIZE_MB: "${DB_MAX_SIZE_MB}"
DB_RETENTION_DAYS: "7" DB_RETENTION_DAYS: "${DB_RETENTION_DAYS}"
DB_MAX_SQL_LENGTH: "1000000000" DB_MAX_SQL_LENGTH: "${DB_MAX_SQL_LENGTH}"
DB_MAX_SQL_VARIABLES: "32766" DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}"
DB_WAL_AUTOCHECKPOINT_PAGES: "1000" DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}"
SHARED_SECRET: "your_shared_secret" SHARED_SECRET: "${SHARED_SECRET}"
restart: unless-stopped restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
db: db:
image: timescale/timescaledb:latest-pg14 image: timescale/timescaledb:latest-pg14
@ -31,11 +36,44 @@ services:
environment: environment:
POSTGRES_DB: dereth POSTGRES_DB: dereth
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes: volumes:
- timescale-data:/var/lib/postgresql/data - timescale-data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "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: volumes:
timescale-data: timescale-data:

38
main.py
View file

@ -381,12 +381,44 @@ async def ws_live_updates(websocket: WebSocket):
browser_conns.remove(websocket) browser_conns.remove(websocket)
# -------------------- static frontend --------------------------- ## -------------------- static frontend ---------------------------
# static frontend ## (static mount moved to end of file, below API routes)
app.mount("/", StaticFiles(directory="static", html=True), name="static")
# list routes for convenience # list routes for convenience
print("🔍 Registered routes:") print("🔍 Registered routes:")
for route in app.routes: for route in app.routes:
if isinstance(route, APIRoute): if isinstance(route, APIRoute):
print(f"{route.path} -> {route.methods}") 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")

View file

@ -12,6 +12,8 @@ const tooltip = document.getElementById('tooltip');
let socket; let socket;
// Keep track of open chat windows: character_name -> DOM element // Keep track of open chat windows: character_name -> DOM element
const chatWindows = {}; const chatWindows = {};
// Keep track of open stats windows: character_name -> DOM element
const statsWindows = {};
/* ---------- constants ------------------------------------------- */ /* ---------- constants ------------------------------------------- */
const MAX_Z = 10; 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 // 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 // Maximum number of lines to retain in each chat window scrollback
const MAX_CHAT_LINES = 1000; const MAX_CHAT_LINES = 1000;
// Map numeric chat color codes to CSS hex colors // Map numeric chat color codes to CSS hex colors
@ -134,6 +137,59 @@ function worldToPx(ew, ns) {
return { x, y }; 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 = () => const applyTransform = () =>
group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;
@ -280,6 +336,15 @@ function render(players) {
showChatWindow(p.character_name); showChatWindow(p.character_name);
}); });
li.appendChild(chatBtn); 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); list.appendChild(li);
}); });
} }

View file

@ -220,7 +220,7 @@ body {
} }
/* ---------- chat window styling ------------------------------- */ /* ---------- chat window styling ------------------------------- */
.chat-btn { .chat-btn, .stats-btn {
margin-top: 4px; margin-top: 4px;
padding: 2px 6px; padding: 2px 6px;
background: var(--accent); background: var(--accent);
@ -231,7 +231,7 @@ body {
cursor: pointer; cursor: pointer;
} }
.chat-window { .chat-window, .stats-window {
position: absolute; position: absolute;
top: 10px; top: 10px;
/* position window to start just right of the sidebar */ /* position window to start just right of the sidebar */
@ -319,3 +319,24 @@ body.noselect, body.noselect * {
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: 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;
}