New version with grafana
This commit is contained in:
parent
f86ad9a542
commit
b2f649a489
6 changed files with 201 additions and 21 deletions
30
README.md
30
README.md
|
|
@ -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
|
||||||
|
|
@ -74,6 +77,22 @@ Start the server using Uvicorn:
|
||||||
```bash
|
```bash
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -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.
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
38
main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue