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:
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()
|
||||
Reference in New Issue
Block a user