Initial commit: Yodmon Yodeck→Zabbix bridge
- Yodeck API poller (every 10 min, paginated, 310 players) - SQLite persistence (players + activity logs) - SNMP v2c agent via net-snmp pass_persist - Zabbix API auto host creation/update (6.0+) - Flask web dashboard with live player status and log - Docker deployment with persistent volume - dev_server.py for local testing without Docker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Database (persistent data — not versioned)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Claude Code internal
|
||||||
|
.claude/
|
||||||
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this project does
|
||||||
|
|
||||||
|
Yodmon is a bridge between the **Yodeck** digital signage API and **Zabbix** monitoring. It polls the Yodeck API every 10 minutes, stores player state in SQLite, exposes the data via SNMP v2c (for Zabbix to poll every minute), auto-creates/updates Zabbix hosts, and provides a web dashboard.
|
||||||
|
|
||||||
|
## Running locally (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install flask requests apscheduler
|
||||||
|
python dev_server.py # web UI at http://localhost:8080
|
||||||
|
python snmp_test.py # print OID tree to verify SNMP data
|
||||||
|
python snmp_test.py --filter <name_or_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
`dev_server.py` sets `DB_PATH` to `./data/yodmon.db` and skips SNMP (Linux/Docker only).
|
||||||
|
|
||||||
|
## Running in Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # SNMP on :161/udp, web UI on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Before starting, set `APP_HOST` in `docker-compose.yml` to the host IP reachable by Zabbix, and optionally fill in `ZABBIX_URL` / `ZABBIX_PASSWORD`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings come from environment variables (see `app/config.py`). Key ones:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `YODECK_API_TOKEN` | — | `Token yodeck:<secret>` format |
|
||||||
|
| `APP_HOST` | `127.0.0.1` | IP Zabbix uses to reach this app's SNMP agent |
|
||||||
|
| `ENTERPRISE_OID` | `.1.3.6.1.4.1.99999` | Root OID for all player data |
|
||||||
|
| `SNMP_COMMUNITY` | `public` | SNMPv2c community string |
|
||||||
|
| `ZABBIX_URL` | `""` | Leave empty to disable Zabbix auto-sync |
|
||||||
|
| `DB_PATH` | `/data/yodmon.db` | SQLite file path |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Yodeck API (every 10 min)
|
||||||
|
└── app/scheduler.py → app/yodeck.py fetches all screens (paginated)
|
||||||
|
→ app/database.py upserts players + writes logs
|
||||||
|
→ app/zabbix.py creates/updates Zabbix hosts via JSON-RPC API
|
||||||
|
|
||||||
|
Zabbix SNMP poll (every 1 min)
|
||||||
|
└── snmpd (net-snmp) → snmp/pass_persist.py reads SQLite, serves OIDs over stdin/stdout
|
||||||
|
|
||||||
|
Web UI
|
||||||
|
└── app/web.py (Flask) → templates/index.html shows player table + activity log
|
||||||
|
```
|
||||||
|
|
||||||
|
**SNMP OID structure** — `ENTERPRISE_OID.1.1.<column>.<yodeck_id>`:
|
||||||
|
- col 1 `STRING` hostname (`yodeck#<id>`)
|
||||||
|
- col 2 `STRING` display name
|
||||||
|
- col 3 `INTEGER` online (0/1)
|
||||||
|
- col 4 `STRING` last_seen (ISO-8601)
|
||||||
|
- col 5 `INTEGER` updating (0/1)
|
||||||
|
- col 6 `INTEGER` registered (0/1)
|
||||||
|
|
||||||
|
The Yodeck ID is used directly as the SNMP table index, so OIDs are stable and predictable (e.g. `.1.3.6.1.4.1.99999.1.1.3.54239` = online status of player 54239).
|
||||||
|
|
||||||
|
**Database** — two tables in `yodmon.db`:
|
||||||
|
- `players` — one row per Yodeck player, upserted on each poll
|
||||||
|
- `logs` — append-only activity log; event types: `yodeck_fetch`, `zabbix_sync`, `snmp_transfer`, `error`
|
||||||
|
|
||||||
|
**Docker entrypoint** (`entrypoint.sh`) templates `/etc/snmp/snmpd.conf` from env vars at startup, then starts `snmpd` in the background before handing off to `python main.py`.
|
||||||
|
|
||||||
|
**Zabbix integration** (`app/zabbix.py`) targets Zabbix 6.0+ API (`item type 20` = SNMP agent). Set `ZABBIX_URL` to enable; the sync runs after every successful Yodeck poll.
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install net-snmp daemon
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
snmpd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x /app/snmp/pass_persist.py /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Persistent data lives here (mount a volume to this path)
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
EXPOSE 161/udp
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
26
app/config.py
Normal file
26
app/config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Yodeck API
|
||||||
|
YODECK_API_TOKEN = os.environ.get('YODECK_API_TOKEN', '')
|
||||||
|
YODECK_API_BASE = 'https://app.yodeck.com/api/v2'
|
||||||
|
YODECK_POLL_INTERVAL_MINUTES = int(os.environ.get('YODECK_POLL_INTERVAL_MINUTES', '10'))
|
||||||
|
|
||||||
|
# Zabbix (all optional — leave ZABBIX_URL empty to disable)
|
||||||
|
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
|
||||||
|
ZABBIX_USER = os.environ.get('ZABBIX_USER', 'Admin')
|
||||||
|
ZABBIX_PASSWORD = os.environ.get('ZABBIX_PASSWORD', '')
|
||||||
|
ZABBIX_HOST_GROUP = os.environ.get('ZABBIX_HOST_GROUP', 'Yodeck Players')
|
||||||
|
ZABBIX_SNMP_COMMUNITY = os.environ.get('ZABBIX_SNMP_COMMUNITY', 'public')
|
||||||
|
# IP/hostname of this app reachable by the Zabbix server for SNMP polling
|
||||||
|
APP_HOST = os.environ.get('APP_HOST', '127.0.0.1')
|
||||||
|
|
||||||
|
# SNMP
|
||||||
|
SNMP_COMMUNITY = os.environ.get('SNMP_COMMUNITY', 'public')
|
||||||
|
# Private Enterprise Number OID used for all player data
|
||||||
|
ENTERPRISE_OID = os.environ.get('ENTERPRISE_OID', '.1.3.6.1.4.1.99999')
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_PATH = os.environ.get('DB_PATH', '/data/yodmon.db')
|
||||||
|
|
||||||
|
# Web UI
|
||||||
|
WEB_PORT = int(os.environ.get('WEB_PORT', '8080'))
|
||||||
107
app/database.py
Normal file
107
app/database.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
||||||
|
conn = _conn()
|
||||||
|
conn.executescript('''
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
online INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_seen TEXT,
|
||||||
|
updating INTEGER NOT NULL DEFAULT 0,
|
||||||
|
registered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
workspace_name TEXT,
|
||||||
|
player_type TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
details TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(timestamp DESC);
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_player(p):
|
||||||
|
state = p.get('state', {})
|
||||||
|
workspace = p.get('workspace', {})
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn = _conn()
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO players (id, name, online, last_seen, updating, registered,
|
||||||
|
workspace_name, player_type, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
online = excluded.online,
|
||||||
|
last_seen = excluded.last_seen,
|
||||||
|
updating = excluded.updating,
|
||||||
|
registered = excluded.registered,
|
||||||
|
workspace_name = excluded.workspace_name,
|
||||||
|
player_type = excluded.player_type,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
''', (
|
||||||
|
p['id'],
|
||||||
|
p['name'],
|
||||||
|
1 if state.get('online') else 0,
|
||||||
|
state.get('last_seen'),
|
||||||
|
1 if state.get('updating') else 0,
|
||||||
|
1 if state.get('registered') else 0,
|
||||||
|
workspace.get('name'),
|
||||||
|
p.get('player_type'),
|
||||||
|
now,
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_players():
|
||||||
|
conn = _conn()
|
||||||
|
rows = conn.execute('SELECT * FROM players ORDER BY name').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_player_counts():
|
||||||
|
conn = _conn()
|
||||||
|
total = conn.execute('SELECT COUNT(*) FROM players').fetchone()[0]
|
||||||
|
online = conn.execute('SELECT COUNT(*) FROM players WHERE online = 1').fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return total, online
|
||||||
|
|
||||||
|
|
||||||
|
def add_log(event_type, message, details=None):
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn = _conn()
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO logs (timestamp, event_type, message, details) VALUES (?, ?, ?, ?)',
|
||||||
|
(now, event_type, message, json.dumps(details) if details else None),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_logs(limit=200):
|
||||||
|
conn = _conn()
|
||||||
|
rows = conn.execute('SELECT * FROM logs ORDER BY id DESC LIMIT ?', (limit,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
40
app/scheduler.py
Normal file
40
app/scheduler.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import logging
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from app import database as db
|
||||||
|
from app import yodeck as yd
|
||||||
|
from app.zabbix import sync_to_zabbix
|
||||||
|
from app.config import YODECK_POLL_INTERVAL_MINUTES
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def poll_yodeck():
|
||||||
|
log.info("Polling Yodeck API …")
|
||||||
|
try:
|
||||||
|
players = yd.get_all_screens()
|
||||||
|
for p in players:
|
||||||
|
db.upsert_player(p)
|
||||||
|
msg = f"Fetched {len(players)} players from Yodeck API"
|
||||||
|
db.add_log('yodeck_fetch', msg, {'count': len(players)})
|
||||||
|
log.info(msg)
|
||||||
|
# Sync host list to Zabbix after a successful fetch
|
||||||
|
sync_to_zabbix(players, db.add_log)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = f"Yodeck poll failed: {exc}"
|
||||||
|
db.add_log('error', msg)
|
||||||
|
log.error(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
scheduler = BackgroundScheduler(daemon=True)
|
||||||
|
scheduler.add_job(
|
||||||
|
poll_yodeck,
|
||||||
|
'interval',
|
||||||
|
minutes=YODECK_POLL_INTERVAL_MINUTES,
|
||||||
|
id='yodeck_poll',
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
log.info("Scheduler started — Yodeck poll every %d minutes", YODECK_POLL_INTERVAL_MINUTES)
|
||||||
|
# Run an initial poll immediately so the DB is populated at startup
|
||||||
|
poll_yodeck()
|
||||||
|
return scheduler
|
||||||
32
app/web.py
Normal file
32
app/web.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from flask import Flask, jsonify, render_template
|
||||||
|
from app import database as db
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__, template_folder='../templates')
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
total, online = db.get_player_counts()
|
||||||
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
total=total,
|
||||||
|
online=online,
|
||||||
|
players=db.get_all_players(),
|
||||||
|
logs=db.get_recent_logs(200),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route('/api/stats')
|
||||||
|
def api_stats():
|
||||||
|
total, online = db.get_player_counts()
|
||||||
|
return jsonify({'total': total, 'online': online, 'offline': total - online})
|
||||||
|
|
||||||
|
@app.route('/api/players')
|
||||||
|
def api_players():
|
||||||
|
return jsonify(db.get_all_players())
|
||||||
|
|
||||||
|
@app.route('/api/logs')
|
||||||
|
def api_logs():
|
||||||
|
return jsonify(db.get_recent_logs(100))
|
||||||
|
|
||||||
|
return app
|
||||||
25
app/yodeck.py
Normal file
25
app/yodeck.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from app.config import YODECK_API_TOKEN, YODECK_API_BASE
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_screens():
|
||||||
|
"""Fetch all screens/players from the Yodeck API (handles pagination)."""
|
||||||
|
if not YODECK_API_TOKEN:
|
||||||
|
raise RuntimeError("YODECK_API_TOKEN is not configured")
|
||||||
|
|
||||||
|
headers = {'Authorization': f'Token {YODECK_API_TOKEN}'}
|
||||||
|
players = []
|
||||||
|
url = f'{YODECK_API_BASE}/screens/'
|
||||||
|
|
||||||
|
while url:
|
||||||
|
log.debug("GET %s", url)
|
||||||
|
resp = requests.get(url, headers=headers, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
players.extend(data.get('results', []))
|
||||||
|
url = data.get('next')
|
||||||
|
|
||||||
|
return players
|
||||||
176
app/zabbix.py
Normal file
176
app/zabbix.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Zabbix API client for automatic host management.
|
||||||
|
|
||||||
|
Each Yodeck player becomes a Zabbix host with:
|
||||||
|
- hostname : yodeck#<yodeck_id>
|
||||||
|
- visible name: the Yodeck player's display name
|
||||||
|
- SNMP v2c interface pointing to this app (APP_HOST:161)
|
||||||
|
- Four SNMP items: online, last_seen, updating, registered
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from app.config import (
|
||||||
|
ZABBIX_URL, ZABBIX_USER, ZABBIX_PASSWORD,
|
||||||
|
ZABBIX_HOST_GROUP, ZABBIX_SNMP_COMMUNITY,
|
||||||
|
APP_HOST, ENTERPRISE_OID,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SNMP_PORT = 161
|
||||||
|
|
||||||
|
# OID column indices (must match snmp/pass_persist.py)
|
||||||
|
COL_HOSTNAME = 1
|
||||||
|
COL_NAME = 2
|
||||||
|
COL_ONLINE = 3
|
||||||
|
COL_LAST_SEEN = 4
|
||||||
|
COL_UPDATING = 5
|
||||||
|
COL_REGISTERED = 6
|
||||||
|
|
||||||
|
|
||||||
|
class ZabbixClient:
|
||||||
|
def __init__(self):
|
||||||
|
self._url = f"{ZABBIX_URL.rstrip('/')}/api_jsonrpc.php"
|
||||||
|
self._auth = None
|
||||||
|
self._id = 0
|
||||||
|
|
||||||
|
def _call(self, method, params):
|
||||||
|
self._id += 1
|
||||||
|
payload = {'jsonrpc': '2.0', 'method': method, 'params': params, 'id': self._id}
|
||||||
|
if self._auth:
|
||||||
|
payload['auth'] = self._auth
|
||||||
|
resp = requests.post(self._url, json=payload, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
if 'error' in body:
|
||||||
|
raise RuntimeError(f"Zabbix [{method}]: {body['error'].get('data', body['error'])}")
|
||||||
|
return body['result']
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
self._auth = self._call('user.login', {'user': ZABBIX_USER, 'password': ZABBIX_PASSWORD})
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
try:
|
||||||
|
self._call('user.logout', [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._auth = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ groups
|
||||||
|
|
||||||
|
def ensure_hostgroup(self, name):
|
||||||
|
existing = self._call('hostgroup.get', {'filter': {'name': name}, 'output': ['groupid']})
|
||||||
|
if existing:
|
||||||
|
return existing[0]['groupid']
|
||||||
|
return self._call('hostgroup.create', {'name': name})['groupids'][0]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ hosts
|
||||||
|
|
||||||
|
def get_hosts_in_group(self, groupid):
|
||||||
|
return self._call('host.get', {
|
||||||
|
'groupids': groupid,
|
||||||
|
'output': ['hostid', 'host', 'name'],
|
||||||
|
})
|
||||||
|
|
||||||
|
def create_host(self, hostname, visible_name, groupid):
|
||||||
|
result = self._call('host.create', {
|
||||||
|
'host': hostname,
|
||||||
|
'name': visible_name,
|
||||||
|
'groups': [{'groupid': groupid}],
|
||||||
|
'interfaces': [{
|
||||||
|
'type': 2, # SNMP
|
||||||
|
'main': 1,
|
||||||
|
'useip': 1,
|
||||||
|
'ip': APP_HOST,
|
||||||
|
'dns': '',
|
||||||
|
'port': str(SNMP_PORT),
|
||||||
|
'details': {
|
||||||
|
'version': 2, # SNMPv2c
|
||||||
|
'community': ZABBIX_SNMP_COMMUNITY,
|
||||||
|
'bulk': 1,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return result['hostids'][0]
|
||||||
|
|
||||||
|
def update_host_name(self, hostid, visible_name):
|
||||||
|
self._call('host.update', {'hostid': hostid, 'name': visible_name})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ items
|
||||||
|
|
||||||
|
def _get_interface_id(self, hostid):
|
||||||
|
ifaces = self._call('hostinterface.get', {
|
||||||
|
'hostids': hostid, 'output': ['interfaceid'],
|
||||||
|
})
|
||||||
|
return ifaces[0]['interfaceid'] if ifaces else None
|
||||||
|
|
||||||
|
def _create_item(self, hostid, interfaceid, name, key, oid, value_type):
|
||||||
|
"""
|
||||||
|
type 20 = SNMP agent (Zabbix 6.0+)
|
||||||
|
value_type 3 = unsigned int, 4 = text
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._call('item.create', {
|
||||||
|
'hostid': hostid,
|
||||||
|
'interfaceid': interfaceid,
|
||||||
|
'name': name,
|
||||||
|
'key_': key,
|
||||||
|
'type': 20,
|
||||||
|
'snmp_oid': oid,
|
||||||
|
'value_type': value_type,
|
||||||
|
'delay': '1m',
|
||||||
|
'history': '90d',
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not create item '%s' on host %s: %s", name, hostid, exc)
|
||||||
|
|
||||||
|
def add_player_items(self, hostid, yodeck_id):
|
||||||
|
ifid = self._get_interface_id(hostid)
|
||||||
|
if not ifid:
|
||||||
|
log.warning("No SNMP interface found for host %s", hostid)
|
||||||
|
return
|
||||||
|
base = f"{ENTERPRISE_OID}.1.1"
|
||||||
|
self._create_item(hostid, ifid, 'Online', 'yodeck.online', f'{base}.{COL_ONLINE}.{yodeck_id}', value_type=3)
|
||||||
|
self._create_item(hostid, ifid, 'Last Seen', 'yodeck.last_seen', f'{base}.{COL_LAST_SEEN}.{yodeck_id}', value_type=4)
|
||||||
|
self._create_item(hostid, ifid, 'Updating', 'yodeck.updating', f'{base}.{COL_UPDATING}.{yodeck_id}', value_type=3)
|
||||||
|
self._create_item(hostid, ifid, 'Registered', 'yodeck.registered', f'{base}.{COL_REGISTERED}.{yodeck_id}', value_type=3)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_to_zabbix(players, add_log_fn):
|
||||||
|
"""Sync player list to Zabbix: create missing hosts, update names."""
|
||||||
|
if not ZABBIX_URL:
|
||||||
|
log.debug("ZABBIX_URL not configured — skipping Zabbix sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
zbx = ZabbixClient()
|
||||||
|
try:
|
||||||
|
zbx.login()
|
||||||
|
groupid = zbx.ensure_hostgroup(ZABBIX_HOST_GROUP)
|
||||||
|
existing = {h['host']: h for h in zbx.get_hosts_in_group(groupid)}
|
||||||
|
|
||||||
|
created = updated = 0
|
||||||
|
for player in players:
|
||||||
|
yid = player['id']
|
||||||
|
hostname = f"yodeck#{yid}"
|
||||||
|
visible = player['name']
|
||||||
|
|
||||||
|
if hostname not in existing:
|
||||||
|
hostid = zbx.create_host(hostname, visible, groupid)
|
||||||
|
zbx.add_player_items(hostid, yid)
|
||||||
|
created += 1
|
||||||
|
log.info("Created Zabbix host: %s (%s)", hostname, visible)
|
||||||
|
else:
|
||||||
|
if existing[hostname]['name'] != visible:
|
||||||
|
zbx.update_host_name(existing[hostname]['hostid'], visible)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
msg = f"Zabbix sync complete: {created} created, {updated} updated ({len(players)} total)"
|
||||||
|
add_log_fn('zabbix_sync', msg, {'created': created, 'updated': updated, 'total': len(players)})
|
||||||
|
log.info(msg)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
msg = f"Zabbix sync failed: {exc}"
|
||||||
|
add_log_fn('error', msg)
|
||||||
|
log.error(msg)
|
||||||
|
finally:
|
||||||
|
zbx.logout()
|
||||||
51
dev_server.py
Normal file
51
dev_server.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Local development server for Yodmon.
|
||||||
|
|
||||||
|
Runs the web UI and Yodeck polling without Docker or snmpd.
|
||||||
|
SNMP is not available in this mode — use Docker for full testing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pip install flask requests apscheduler
|
||||||
|
python dev_server.py
|
||||||
|
Open http://localhost:8080
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# Override paths/settings before importing app modules.
|
||||||
|
# All of these can also be set as real environment variables.
|
||||||
|
os.environ.setdefault('DB_PATH', os.path.join(_HERE, 'data', 'yodmon.db'))
|
||||||
|
os.environ.setdefault('YODECK_API_TOKEN', 'yodeck:fXQKm1hLvJY88necL3GiLVntpmyNS5BpKp8MpDK8GH2UvCPrg8BeHwpBSQaEtF0q')
|
||||||
|
os.environ.setdefault('WEB_PORT', '8080')
|
||||||
|
|
||||||
|
# Zabbix sync is optional — leave ZABBIX_URL empty to skip it
|
||||||
|
os.environ.setdefault('ZABBIX_URL', '')
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import DB_PATH, WEB_PORT
|
||||||
|
from app.database import init_db
|
||||||
|
from app.scheduler import start_scheduler
|
||||||
|
from app.web import create_app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Yodmon — dev server")
|
||||||
|
print(f" DB : {DB_PATH}")
|
||||||
|
print(f" URL : http://localhost:{WEB_PORT}")
|
||||||
|
print(" SNMP: not available (Linux/Docker only)")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
||||||
|
init_db()
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.run(host='127.0.0.1', port=WEB_PORT, use_reloader=False)
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
yodmon:
|
||||||
|
build: .
|
||||||
|
container_name: yodmon
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "161:161/udp" # SNMP — polled by Zabbix every minute
|
||||||
|
- "8080:8080" # Web UI
|
||||||
|
volumes:
|
||||||
|
- yodmon_data:/data
|
||||||
|
environment:
|
||||||
|
# ── Yodeck ────────────────────────────────────────────────────────────
|
||||||
|
YODECK_API_TOKEN: "yodeck:fXQKm1hLvJY88necL3GiLVntpmyNS5BpKp8MpDK8GH2UvCPrg8BeHwpBSQaEtF0q"
|
||||||
|
YODECK_POLL_INTERVAL_MINUTES: "10"
|
||||||
|
|
||||||
|
# ── SNMP ──────────────────────────────────────────────────────────────
|
||||||
|
SNMP_COMMUNITY: "public"
|
||||||
|
# Private Enterprise Number OID for this application.
|
||||||
|
# All player data is served under this subtree.
|
||||||
|
ENTERPRISE_OID: ".1.3.6.1.4.1.99999"
|
||||||
|
|
||||||
|
# ── Network ───────────────────────────────────────────────────────────
|
||||||
|
# IP address (or hostname) of THIS host, reachable by the Zabbix server.
|
||||||
|
# Zabbix will send SNMP polls to this address on port 161.
|
||||||
|
APP_HOST: "192.168.1.100" # ← change to your actual host IP
|
||||||
|
|
||||||
|
# ── Zabbix API (optional) ─────────────────────────────────────────────
|
||||||
|
# Leave ZABBIX_URL empty to disable automatic host management.
|
||||||
|
# Requires Zabbix 6.0+.
|
||||||
|
ZABBIX_URL: "" # e.g. "http://zabbix.example.com"
|
||||||
|
ZABBIX_USER: "Admin"
|
||||||
|
ZABBIX_PASSWORD: ""
|
||||||
|
ZABBIX_HOST_GROUP: "Yodeck Players"
|
||||||
|
ZABBIX_SNMP_COMMUNITY: "public"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
yodmon_data:
|
||||||
29
entrypoint.sh
Normal file
29
entrypoint.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
COMMUNITY="${SNMP_COMMUNITY:-public}"
|
||||||
|
ENT_OID="${ENTERPRISE_OID:-.1.3.6.1.4.1.99999}"
|
||||||
|
|
||||||
|
# Write snmpd.conf from environment variables so community/OID are configurable
|
||||||
|
cat > /etc/snmp/snmpd.conf <<EOF
|
||||||
|
# Listen on all interfaces
|
||||||
|
agentAddress udp:0.0.0.0:161
|
||||||
|
|
||||||
|
# Read-only SNMPv2c community
|
||||||
|
rocommunity ${COMMUNITY} default
|
||||||
|
|
||||||
|
# Delegate our private OID tree to the Python pass_persist script
|
||||||
|
pass_persist ${ENT_OID} /app/snmp/pass_persist.py
|
||||||
|
|
||||||
|
# Reduce noise in logs
|
||||||
|
syslog 5
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[entrypoint] snmpd.conf written (community=${COMMUNITY}, oid=${ENT_OID})"
|
||||||
|
|
||||||
|
# Start snmpd in the background
|
||||||
|
snmpd -f -Lo &
|
||||||
|
echo "[entrypoint] snmpd started (PID $!)"
|
||||||
|
|
||||||
|
# Hand off to the Python application
|
||||||
|
exec python main.py
|
||||||
19
main.py
Normal file
19
main.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import WEB_PORT, DB_PATH
|
||||||
|
from app.database import init_db
|
||||||
|
from app.scheduler import start_scheduler
|
||||||
|
from app.web import create_app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
||||||
|
init_db()
|
||||||
|
start_scheduler()
|
||||||
|
flask_app = create_app()
|
||||||
|
flask_app.run(host='0.0.0.0', port=WEB_PORT, use_reloader=False)
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
apscheduler>=3.10.4
|
||||||
137
snmp/pass_persist.py
Normal file
137
snmp/pass_persist.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
net-snmp pass_persist handler for Yodmon.
|
||||||
|
|
||||||
|
Reads player data from the SQLite database and serves it over SNMP v2c.
|
||||||
|
|
||||||
|
OID layout (base = ENTERPRISE_OID.1.1):
|
||||||
|
base.1.<yodeck_id> STRING "yodeck#<id>"
|
||||||
|
base.2.<yodeck_id> STRING player display name
|
||||||
|
base.3.<yodeck_id> INTEGER online (1 = online, 0 = offline)
|
||||||
|
base.4.<yodeck_id> STRING last_seen (ISO-8601 timestamp)
|
||||||
|
base.5.<yodeck_id> INTEGER updating (1 = yes, 0 = no)
|
||||||
|
base.6.<yodeck_id> INTEGER registered (1 = yes, 0 = no)
|
||||||
|
|
||||||
|
pass_persist protocol (net-snmp):
|
||||||
|
stdin: PING | get\n<oid> | getnext\n<oid>
|
||||||
|
stdout: PONG | <oid>\n<type>\n<value> | NONE
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get('DB_PATH', '/data/yodmon.db')
|
||||||
|
ENTERPRISE_OID = os.environ.get('ENTERPRISE_OID', '.1.3.6.1.4.1.99999')
|
||||||
|
BASE_OID = f"{ENTERPRISE_OID}.1.1"
|
||||||
|
|
||||||
|
# (column_index, snmp_type, value_extractor)
|
||||||
|
COLUMNS = [
|
||||||
|
(1, 'STRING', lambda p: f"yodeck#{p['id']}"),
|
||||||
|
(2, 'STRING', lambda p: p.get('name') or ''),
|
||||||
|
(3, 'INTEGER', lambda p: str(p.get('online', 0))),
|
||||||
|
(4, 'STRING', lambda p: p.get('last_seen') or ''),
|
||||||
|
(5, 'INTEGER', lambda p: str(p.get('updating', 0))),
|
||||||
|
(6, 'INTEGER', lambda p: str(p.get('registered', 0))),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Rate-limit SNMP transfer log entries (at most one per minute)
|
||||||
|
_last_log_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_players():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute('SELECT * FROM players ORDER BY id').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tree():
|
||||||
|
"""Return a sorted list of (oid_tuple, oid_str, snmp_type, value)."""
|
||||||
|
entries = []
|
||||||
|
for player in _get_players():
|
||||||
|
pid = player['id']
|
||||||
|
for col, typ, getter in COLUMNS:
|
||||||
|
oid_str = f"{BASE_OID}.{col}.{pid}"
|
||||||
|
oid_tuple = tuple(int(x) for x in oid_str.lstrip('.').split('.'))
|
||||||
|
entries.append((oid_tuple, oid_str, typ, getter(player)))
|
||||||
|
entries.sort(key=lambda e: e[0])
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _to_tuple(oid_str):
|
||||||
|
return tuple(int(x) for x in oid_str.lstrip('.').split('.'))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_transfer():
|
||||||
|
global _last_log_ts
|
||||||
|
import time
|
||||||
|
now = time.time()
|
||||||
|
if now - _last_log_ts < 60:
|
||||||
|
return
|
||||||
|
_last_log_ts = now
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO logs (timestamp, event_type, message) VALUES (?, ?, ?)",
|
||||||
|
(datetime.now(timezone.utc).isoformat(), 'snmp_transfer', 'SNMP data served to Zabbix'),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _reply(oid_str, typ, value):
|
||||||
|
_log_transfer()
|
||||||
|
sys.stdout.write(f"{oid_str}\n{typ}\n{value}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
cmd = line.strip()
|
||||||
|
|
||||||
|
if cmd == 'PING':
|
||||||
|
sys.stdout.write('PONG\n')
|
||||||
|
sys.stdout.flush()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd in ('get', 'getnext'):
|
||||||
|
raw_oid = sys.stdin.readline().strip()
|
||||||
|
tree = _build_tree()
|
||||||
|
|
||||||
|
if cmd == 'get':
|
||||||
|
tup = _to_tuple(raw_oid)
|
||||||
|
match = next((e for e in tree if e[0] == tup), None)
|
||||||
|
if match:
|
||||||
|
_reply(match[1], match[2], match[3])
|
||||||
|
else:
|
||||||
|
sys.stdout.write('NONE\n')
|
||||||
|
|
||||||
|
else: # getnext
|
||||||
|
tup = _to_tuple(raw_oid)
|
||||||
|
nxt = next((e for e in tree if e[0] > tup), None)
|
||||||
|
if nxt:
|
||||||
|
_reply(nxt[1], nxt[2], nxt[3])
|
||||||
|
else:
|
||||||
|
sys.stdout.write('NONE\n')
|
||||||
|
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except (EOFError, BrokenPipeError, KeyboardInterrupt):
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
sys.stderr.write(f"pass_persist error: {exc}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
75
snmp_test.py
Normal file
75
snmp_test.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Print the full SNMP OID tree that would be served by the SNMP agent.
|
||||||
|
|
||||||
|
Useful for verifying player data and OID mapping without running snmpd.
|
||||||
|
Run this after dev_server.py has done at least one Yodeck poll.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python snmp_test.py [--filter <name_substring>]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python snmp_test.py # all players
|
||||||
|
python snmp_test.py --filter TRN_FRONT # only matching names
|
||||||
|
python snmp_test.py --filter 54239 # by Yodeck ID
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
os.environ.setdefault('DB_PATH', os.path.join(_HERE, 'data', 'yodmon.db'))
|
||||||
|
os.environ.setdefault('ENTERPRISE_OID', '.1.3.6.1.4.1.99999')
|
||||||
|
|
||||||
|
from app.config import DB_PATH, ENTERPRISE_OID
|
||||||
|
from app.database import get_all_players, get_player_counts
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
(1, 'STRING', 'hostname', lambda p: f"yodeck#{p['id']}"),
|
||||||
|
(2, 'STRING', 'name', lambda p: p.get('name') or ''),
|
||||||
|
(3, 'INTEGER', 'online', lambda p: str(p.get('online', 0))),
|
||||||
|
(4, 'STRING', 'last_seen', lambda p: p.get('last_seen') or ''),
|
||||||
|
(5, 'INTEGER', 'updating', lambda p: str(p.get('updating', 0))),
|
||||||
|
(6, 'INTEGER', 'registered', lambda p: str(p.get('registered', 0))),
|
||||||
|
]
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Show Yodmon SNMP OID tree')
|
||||||
|
parser.add_argument('--filter', metavar='TEXT', default='',
|
||||||
|
help='Only show players whose name or ID contains TEXT')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
players = get_all_players()
|
||||||
|
total, online = get_player_counts()
|
||||||
|
base = ENTERPRISE_OID.lstrip('.') + '.1.1'
|
||||||
|
|
||||||
|
if args.filter:
|
||||||
|
players = [p for p in players
|
||||||
|
if args.filter.lower() in p['name'].lower()
|
||||||
|
or args.filter in str(p['id'])]
|
||||||
|
|
||||||
|
print(f"DB : {DB_PATH}")
|
||||||
|
print(f"Enterprise OID : {ENTERPRISE_OID}")
|
||||||
|
print(f"Base OID : .{base}.<column>.<yodeck_id>")
|
||||||
|
print(f"Players : {total} total, {online} online")
|
||||||
|
if args.filter:
|
||||||
|
print(f"Filter : '{args.filter}' -> {len(players)} match(es)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
col_header = f"{'OID':<52} {'TYPE':<10} {'VALUE'}"
|
||||||
|
print(col_header)
|
||||||
|
print('-' * len(col_header))
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
pid = p['id']
|
||||||
|
print(f" -- yodeck#{pid} ({p['name']})")
|
||||||
|
for col, typ, label, getter in COLUMNS:
|
||||||
|
oid = f".{base}.{col}.{pid}"
|
||||||
|
val = getter(p)
|
||||||
|
print(f" {oid:<50} {typ:<10} {val}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"Total OIDs: {len(players) * len(COLUMNS)}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
150
templates/index.html
Normal file
150
templates/index.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Yodmon – Yodeck Monitor</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<meta http-equiv="refresh" content="60">
|
||||||
|
<style>
|
||||||
|
body { background: #f0f2f5; }
|
||||||
|
.stat-card { border: none; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
||||||
|
.online { color: #198754; font-weight: 600; }
|
||||||
|
.offline { color: #dc3545; font-weight: 600; }
|
||||||
|
.badge-yodeck_fetch { background: #0d6efd; }
|
||||||
|
.badge-zabbix_sync { background: #6f42c1; }
|
||||||
|
.badge-snmp_transfer { background: #20c997; }
|
||||||
|
.badge-error { background: #dc3545; }
|
||||||
|
.scroll-table { max-height: 420px; overflow-y: auto; }
|
||||||
|
thead.sticky-top th { position: sticky; top: 0; z-index: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-dark bg-dark mb-4 px-3">
|
||||||
|
<span class="navbar-brand fw-bold fs-5">Yodmon</span>
|
||||||
|
<span class="text-secondary small">Yodeck → Zabbix Bridge</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-center py-3 h-100">
|
||||||
|
<div class="display-6 fw-bold text-primary">{{ total }}</div>
|
||||||
|
<div class="text-muted small">Total Players</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-center py-3 h-100">
|
||||||
|
<div class="display-6 fw-bold text-success">{{ online }}</div>
|
||||||
|
<div class="text-muted small">Online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-center py-3 h-100">
|
||||||
|
<div class="display-6 fw-bold text-danger">{{ total - online }}</div>
|
||||||
|
<div class="text-muted small">Offline</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-center py-3 h-100">
|
||||||
|
<div class="fs-6 fw-semibold text-secondary">
|
||||||
|
{% set last_fetch = logs | selectattr('event_type', 'equalto', 'yodeck_fetch') | first %}
|
||||||
|
{% if last_fetch %}{{ last_fetch.timestamp[:19] }} UTC{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">Last API Fetch</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player table -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold">Players</span>
|
||||||
|
<span class="badge bg-secondary">{{ total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="scroll-table">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th>Yodeck ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Workspace</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Seen (UTC)</th>
|
||||||
|
<th>Updating</th>
|
||||||
|
<th>Registered</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in players %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted font-monospace">{{ p.id }}</td>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td>{{ p.workspace_name or '—' }}</td>
|
||||||
|
<td>{{ p.player_type or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p.online %}
|
||||||
|
<span class="online">● Online</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="offline">● Offline</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="font-monospace small">{{ (p.last_seen or '—')[:19] }}</td>
|
||||||
|
<td>{% if p.updating %}<span class="text-warning fw-semibold">Yes</span>{% else %}No{% endif %}</td>
|
||||||
|
<td>{% if p.registered %}<span class="text-success">✓</span>{% else %}<span class="text-danger">✗</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity log -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold">Activity Log</span>
|
||||||
|
<span class="badge bg-secondary">last 200</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="scroll-table">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-dark sticky-top">
|
||||||
|
<tr>
|
||||||
|
<th style="width:180px">Timestamp (UTC)</th>
|
||||||
|
<th style="width:160px">Event</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in logs %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-monospace small text-muted">{{ l.timestamp[:19] }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ l.event_type }}">{{ l.event_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ l.message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center text-muted small pb-3">
|
||||||
|
Page auto-refreshes every 60 seconds |
|
||||||
|
<a href="/api/stats" class="text-muted">API: stats</a>
|
||||||
|
<a href="/api/players" class="text-muted">players</a>
|
||||||
|
<a href="/api/logs" class="text-muted">logs</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user