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:
2026-04-17 09:31:00 +02:00
commit 9fc3e97546
18 changed files with 1027 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View 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
View 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
View File

26
app/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
flask>=3.0.0
requests>=2.31.0
apscheduler>=3.10.4

137
snmp/pass_persist.py Normal file
View 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
View 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
View 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 &nbsp;|&nbsp;
<a href="/api/stats" class="text-muted">API: stats</a> &nbsp;
<a href="/api/players" class="text-muted">players</a> &nbsp;
<a href="/api/logs" class="text-muted">logs</a>
</div>
</body>
</html>