diff --git a/app/config.py b/app/config.py index e3c4097..6c7f3d3 100644 --- a/app/config.py +++ b/app/config.py @@ -12,6 +12,7 @@ ZABBIX_API_TOKEN = os.environ.get('ZABBIX_API_TOKEN', '') 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_TEMPLATE = os.environ.get('ZABBIX_TEMPLATE', 'Yodmon Yodeck Player') 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') diff --git a/app/zabbix.py b/app/zabbix.py index 17758b6..67c8410 100644 --- a/app/zabbix.py +++ b/app/zabbix.py @@ -11,7 +11,7 @@ import logging import requests from app.config import ( ZABBIX_URL, ZABBIX_API_TOKEN, ZABBIX_USER, ZABBIX_PASSWORD, - ZABBIX_HOST_GROUP, ZABBIX_SNMP_COMMUNITY, + ZABBIX_HOST_GROUP, ZABBIX_TEMPLATE, ZABBIX_SNMP_COMMUNITY, APP_HOST, ENTERPRISE_OID, ) @@ -90,6 +90,7 @@ class ZabbixClient: return self._call('host.get', { 'groupids': groupid, 'output': ['hostid', 'host', 'name'], + 'selectParentTemplates': ['templateid'], }) def create_host(self, hostname, visible_name, groupid): @@ -116,6 +117,34 @@ class ZabbixClient: def update_host_name(self, hostid, visible_name): self._call('host.update', {'hostid': hostid, 'name': visible_name}) + # ------------------------------------------------------------------ templates + + def get_template_id(self, name): + result = self._call('template.get', {'filter': {'host': name}, 'output': ['templateid']}) + return result[0]['templateid'] if result else None + + def link_template(self, hostid, templateid): + # host.update with templates replaces the list — fetch existing first to avoid unlinking others + existing = self._call('host.get', { + 'hostids': hostid, + 'selectParentTemplates': ['templateid'], + 'output': [], + }) + current = [{'templateid': t['templateid']} for t in existing[0].get('parentTemplates', [])] + if not any(t['templateid'] == templateid for t in current): + self._call('host.update', {'hostid': hostid, 'templates': current + [{'templateid': templateid}]}) + + def upsert_host_macro(self, hostid, macro, value): + existing = self._call('usermacro.get', { + 'hostids': hostid, + 'filter': {'macro': macro}, + 'output': ['hostmacroid'], + }) + if existing: + self._call('usermacro.update', {'hostmacroid': existing[0]['hostmacroid'], 'value': value}) + else: + self._call('usermacro.create', {'hostid': hostid, 'macro': macro, 'value': value}) + # ------------------------------------------------------------------ items def _get_interface_id(self, hostid): @@ -157,7 +186,7 @@ class ZabbixClient: def sync_to_zabbix(players, add_log_fn): - """Sync player list to Zabbix: create missing hosts, update names.""" + """Sync player list to Zabbix: create missing hosts, update names, link template.""" if not ZABBIX_URL: log.debug("ZABBIX_URL not configured — skipping Zabbix sync") return @@ -165,7 +194,10 @@ def sync_to_zabbix(players, add_log_fn): zbx = ZabbixClient() try: zbx.login() - groupid = zbx.ensure_hostgroup(ZABBIX_HOST_GROUP) + groupid = zbx.ensure_hostgroup(ZABBIX_HOST_GROUP) + template_id = zbx.get_template_id(ZABBIX_TEMPLATE) if ZABBIX_TEMPLATE else None + if ZABBIX_TEMPLATE and not template_id: + log.warning("Template '%s' not found in Zabbix — import zabbix_template.yaml first", ZABBIX_TEMPLATE) existing = {h['host']: h for h in zbx.get_hosts_in_group(groupid)} created = updated = 0 @@ -176,13 +208,25 @@ def sync_to_zabbix(players, add_log_fn): if hostname not in existing: hostid = zbx.create_host(hostname, visible, groupid) - zbx.add_player_items(hostid, yid) + zbx.upsert_host_macro(hostid, '{$YODECK_ID}', str(yid)) + if template_id: + zbx.link_template(hostid, template_id) + else: + 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) + h = existing[hostname] + if h['name'] != visible: + zbx.update_host_name(h['hostid'], visible) updated += 1 + # Link template and set macro on existing hosts that don't have it yet + if template_id: + linked = {t['templateid'] for t in h.get('parentTemplates', [])} + if template_id not in linked: + zbx.upsert_host_macro(h['hostid'], '{$YODECK_ID}', str(yid)) + zbx.link_template(h['hostid'], template_id) + 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)}) diff --git a/docker-compose.yml b/docker-compose.yml index e472e1b..d32df18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: ZABBIX_USER: "" ZABBIX_PASSWORD: "" ZABBIX_HOST_GROUP: "Yodeck Players" + ZABBIX_TEMPLATE: "Yodmon Yodeck Player" ZABBIX_SNMP_COMMUNITY: "public" volumes: diff --git a/snmp/pass_persist.py b/snmp/pass_persist.py index fec070e..bb4d50a 100644 --- a/snmp/pass_persist.py +++ b/snmp/pass_persist.py @@ -7,10 +7,11 @@ Reads player data from the SQLite database and serves it over SNMP v2c. OID layout (base = ENTERPRISE_OID.1.1): base.1. STRING "yodeck-" base.2. STRING player display name - base.3. INTEGER online (1 = online, 0 = offline) - base.4. STRING last_seen (ISO-8601 timestamp) - base.5. INTEGER updating (1 = yes, 0 = no) - base.6. INTEGER registered (1 = yes, 0 = no) + base.3. INTEGER online (1 = online, 0 = offline) + base.4. STRING last_seen (ISO-8601 timestamp) + base.5. INTEGER updating (1 = yes, 0 = no) + base.6. INTEGER registered (1 = yes, 0 = no) + base.7. INTEGER last_seen_unix (Unix timestamp, 0 if unknown) pass_persist protocol (net-snmp): stdin: PING | get\n | getnext\n @@ -25,6 +26,17 @@ 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" +def _iso_to_unix(iso_str): + """Convert ISO-8601 timestamp string to Unix epoch integer, 0 if missing.""" + if not iso_str: + return 0 + try: + dt = datetime.fromisoformat(iso_str.replace('Z', '+00:00')) + return int(dt.timestamp()) + except Exception: + return 0 + + # (column_index, snmp_type, value_extractor) COLUMNS = [ (1, 'STRING', lambda p: f"yodeck-{p['id']}"), @@ -33,6 +45,7 @@ COLUMNS = [ (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))), + (7, 'INTEGER', lambda p: str(_iso_to_unix(p.get('last_seen')))), ] # Rate-limit SNMP transfer log entries (at most one per minute) diff --git a/zabbix_template.yaml b/zabbix_template.yaml new file mode 100644 index 0000000..548ddc0 --- /dev/null +++ b/zabbix_template.yaml @@ -0,0 +1,106 @@ +zabbix_export: + version: '7.4' + template_groups: + - uuid: 7df96b18c230490a9a0a9e2307226338 + name: Templates + templates: + - uuid: a1b2c3d4e5f6789012345678deadbeef + template: Yodmon Yodeck Player + name: Yodmon Yodeck Player + description: | + Yodmon: monitors a Yodeck digital signage player via SNMP. + + Required host macro: {$YODECK_ID} — set automatically by Yodmon. + The SNMP interface must point to the Yodmon host on port 161 with + the configured community string. + + OID base: .1.3.6.1.4.1.99999.1.1..{$YODECK_ID} + groups: + - name: Templates + items: + - uuid: 11111111222233334444555566667701 + name: Online + type: SNMP_AGENT + snmp_oid: '.1.3.6.1.4.1.99999.1.1.3.{$YODECK_ID}' + key: yodeck.online + delay: 1m + history: 90d + value_type: UNSIGNED + description: '1 = online, 0 = offline' + tags: + - tag: yodmon + + - uuid: 11111111222233334444555566667702 + name: Last Seen Timestamp + type: SNMP_AGENT + snmp_oid: '.1.3.6.1.4.1.99999.1.1.7.{$YODECK_ID}' + key: yodeck.last_seen_ts + delay: 1m + history: 90d + value_type: UNSIGNED + description: 'Unix timestamp of last contact with Yodeck cloud (used for age-based trigger)' + tags: + - tag: yodmon + + - uuid: 11111111222233334444555566667703 + name: Last Seen + type: SNMP_AGENT + snmp_oid: '.1.3.6.1.4.1.99999.1.1.4.{$YODECK_ID}' + key: yodeck.last_seen + delay: 1m + history: 90d + value_type: TEXT + description: 'ISO-8601 timestamp of last contact with Yodeck cloud' + tags: + - tag: yodmon + + - uuid: 11111111222233334444555566667704 + name: Updating + type: SNMP_AGENT + snmp_oid: '.1.3.6.1.4.1.99999.1.1.5.{$YODECK_ID}' + key: yodeck.updating + delay: 1m + history: 90d + value_type: UNSIGNED + description: '1 = firmware update in progress, 0 = idle' + tags: + - tag: yodmon + + - uuid: 11111111222233334444555566667705 + name: Registered + type: SNMP_AGENT + snmp_oid: '.1.3.6.1.4.1.99999.1.1.6.{$YODECK_ID}' + key: yodeck.registered + delay: 1m + history: 90d + value_type: UNSIGNED + description: '1 = registered with Yodeck cloud, 0 = not registered' + tags: + - tag: yodmon + + triggers: + - uuid: 22222222333344445555666677778801 + expression: 'max(/Yodmon Yodeck Player/yodeck.online,30m)<1' + name: '{HOST.NAME} is offline' + priority: WARNING + description: | + The player has reported offline status for 30 consecutive minutes. + Check the device, network, and Yodeck dashboard. + tags: + - tag: yodmon + + - uuid: 22222222333344445555666677778802 + expression: 'now()-last(/Yodmon Yodeck Player/yodeck.last_seen_ts)>14400 and last(/Yodmon Yodeck Player/yodeck.last_seen_ts)>0' + name: '{HOST.NAME} not seen for 4+ hours' + priority: WARNING + description: | + The last contact timestamp from Yodeck is more than 4 hours old. + The player may be disconnected or the Yodeck cloud may not be + receiving heartbeats from the device. + tags: + - tag: yodmon + + macros: + - macro: '{$YODECK_ID}' + value: '' + description: 'Yodeck player ID — set automatically by Yodmon on host creation'