Zabbix supply backend rebuild + data-driven model supplies
Rewrite the printer Zabbix integration (Bearer auth, host-by-IP, tag-based supply lookup, ping) and replace the hardcoded toner table with a modelsupplies table + CRUD + seed. Add mock Zabbix server, live test harness, and the Playwright screenshot tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,22 @@
|
||||
"""Zabbix service for real-time printer supply lookups."""
|
||||
"""Zabbix service for real-time printer supply lookups.
|
||||
|
||||
Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and
|
||||
includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from
|
||||
the live integration:
|
||||
|
||||
- Auth via an Authorization: Bearer <token> header (Zabbix 6.0+ / 7.0).
|
||||
The old payload "auth" field is rejected by Zabbix 7.0.
|
||||
- Hosts are named by IP address, so a host is located with
|
||||
host.get filter {host: [ip]}, not by interface address.
|
||||
- Supply levels come from items tagged component=supplies AND type=level,
|
||||
not from a key_ substring search.
|
||||
- Each level item carries a color tag used for display and part lookup.
|
||||
|
||||
Configuration (database Setting overrides env var):
|
||||
ZABBIX_ENABLED: turn the integration on
|
||||
ZABBIX_URL: base URL or full api_jsonrpc.php URL
|
||||
ZABBIX_TOKEN: API token
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
@@ -12,53 +30,59 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZabbixService:
|
||||
"""
|
||||
Zabbix API service for real-time printer supply lookups.
|
||||
"""Zabbix API client for printer supply and ping lookups."""
|
||||
|
||||
Queries Zabbix by IP address to get current supply levels.
|
||||
Use getsuppliesbyip_cached() for cached lookups or
|
||||
getsuppliesbyip() for live data.
|
||||
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
||||
REACHABLE_CHECK_TTL = 60
|
||||
|
||||
Configuration:
|
||||
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
|
||||
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
|
||||
ZABBIX_TOKEN: Zabbix API authentication token
|
||||
"""
|
||||
# quick fail for the reachability probe
|
||||
REACHABLE_TIMEOUT = 1.0
|
||||
# (connect, read) for real API calls; item.get is slow, give it room
|
||||
API_TIMEOUT = (3.0, 5.0)
|
||||
|
||||
CACHE_TTL = 600 # 10 minutes
|
||||
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
|
||||
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
|
||||
SUPPLY_TAGS = [
|
||||
{"tag": "component", "value": "supplies", "operator": 0},
|
||||
{"tag": "type", "value": "level", "operator": 0},
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self._url = None
|
||||
self._token = None
|
||||
self._enabled = None
|
||||
|
||||
# -- configuration -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def isenabled(self) -> bool:
|
||||
"""Check if Zabbix integration is enabled."""
|
||||
# Check database setting first, fall back to env var
|
||||
"""Whether the integration is switched on."""
|
||||
from shopdb.core.models import Setting
|
||||
db_enabled = Setting.get('zabbix_enabled')
|
||||
if db_enabled is not None:
|
||||
return bool(db_enabled)
|
||||
# Fall back to env var for backwards compatibility
|
||||
return current_app.config.get('ZABBIX_ENABLED', False)
|
||||
|
||||
@property
|
||||
def isconfigured(self) -> bool:
|
||||
"""Check if Zabbix is enabled and configured."""
|
||||
"""Enabled, and a URL plus token are present."""
|
||||
if not self.isenabled:
|
||||
return False
|
||||
# Check database settings first, fall back to env vars
|
||||
from shopdb.core.models import Setting
|
||||
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
|
||||
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
|
||||
return bool(self._url and self._token)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""Full JSON-RPC endpoint. Accept a base URL or the full path."""
|
||||
url = (self._url or "").rstrip("/")
|
||||
if url.endswith("api_jsonrpc.php"):
|
||||
return url
|
||||
return f"{url}/api_jsonrpc.php"
|
||||
|
||||
@property
|
||||
def isreachable(self) -> bool:
|
||||
"""Check if Zabbix is reachable (cached for 60 seconds)."""
|
||||
if not self.isenabled or not self.isconfigured:
|
||||
"""Cheap connectivity probe, cached for 60s."""
|
||||
if not self.isconfigured:
|
||||
return False
|
||||
|
||||
cache_key = 'zabbix_reachable'
|
||||
@@ -66,138 +90,165 @@ class ZabbixService:
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Quick connectivity check with 500ms timeout
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self._url}/api_jsonrpc.php",
|
||||
timeout=0.5
|
||||
)
|
||||
reachable = response.status_code in (200, 401, 403, 405)
|
||||
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
|
||||
# any non-5xx answer means the web tier responded, so the server is
|
||||
# up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
|
||||
# wants a POST with json-rpc content type); that still counts.
|
||||
reachable = response.status_code < 500
|
||||
except requests.RequestException:
|
||||
reachable = False
|
||||
|
||||
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
|
||||
logger.debug(f"Zabbix reachability check: {reachable}")
|
||||
logger.debug("Zabbix reachability: %s", reachable)
|
||||
return reachable
|
||||
|
||||
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
|
||||
"""Make a Zabbix API call."""
|
||||
# -- low level call ------------------------------------------------------
|
||||
|
||||
def _apicall(self, method: str, params: Dict) -> Optional[object]:
|
||||
"""One JSON-RPC call. Returns the result, or None on any error."""
|
||||
if not self.isconfigured:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': method,
|
||||
'params': params,
|
||||
'auth': self._token,
|
||||
'id': 1
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": 1,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json-rpc",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self._url}/api_jsonrpc.php",
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
|
||||
headers=headers,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
logger.error(f"Zabbix API error: {data['error']}")
|
||||
return None
|
||||
|
||||
return data.get('result')
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Zabbix API request failed: {e}")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error("Zabbix %s call failed: %s", method, exc)
|
||||
return None
|
||||
|
||||
def gethostbyip(self, ip: str) -> Optional[Dict]:
|
||||
"""Find a Zabbix host by IP address."""
|
||||
result = self._apicall('host.get', {
|
||||
'output': ['hostid', 'host', 'name'],
|
||||
'filter': {'ip': ip},
|
||||
'selectInterfaces': ['ip']
|
||||
})
|
||||
if "error" in data:
|
||||
logger.error("Zabbix %s error: %s", method, data["error"])
|
||||
return None
|
||||
|
||||
return data.get("result")
|
||||
|
||||
# -- host / item lookups -------------------------------------------------
|
||||
|
||||
def gethostidbyip(self, ip: str) -> Optional[str]:
|
||||
"""Host id for a printer. Hosts are named by IP in this Zabbix."""
|
||||
result = self._apicall("host.get", {
|
||||
"output": ["hostid"],
|
||||
"filter": {"host": [ip]},
|
||||
})
|
||||
if result:
|
||||
return result[0] if result else None
|
||||
return result[0].get("hostid")
|
||||
return None
|
||||
|
||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Get printer supply levels by IP address.
|
||||
def _extract_color(self, item: Dict) -> str:
|
||||
"""Pull and normalise the color tag, falling back to the item name."""
|
||||
color = ""
|
||||
for tag in item.get("tags", []) or []:
|
||||
if tag.get("tag") == "color":
|
||||
color = (tag.get("value") or "").lower()
|
||||
break
|
||||
if "black" in color:
|
||||
color = "black"
|
||||
elif color in ("grey", "gray"):
|
||||
color = "gray"
|
||||
|
||||
Returns list of supplies with name and level percentage.
|
||||
if not color:
|
||||
name = (item.get("name") or "").lower()
|
||||
for candidate in ("cyan", "magenta", "yellow", "black"):
|
||||
if candidate in name:
|
||||
color = candidate
|
||||
break
|
||||
if not color and ("gray" in name or "grey" in name):
|
||||
color = "gray"
|
||||
return color
|
||||
|
||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""Current supply levels for a printer, by IP.
|
||||
|
||||
Returns a list of dicts {name, level, color, itemid, status, state},
|
||||
or None if the host is not in Zabbix. Drum/maintenance items are kept
|
||||
(callers decide what to surface); only disabled (status=1) and
|
||||
unsupported (state=1) items are dropped, matching the classic report.
|
||||
"""
|
||||
# Find host by IP
|
||||
host = self.gethostbyip(ip)
|
||||
if not host:
|
||||
logger.debug(f"No Zabbix host found for IP {ip}")
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
logger.debug("No Zabbix host for IP %s", ip)
|
||||
return None
|
||||
|
||||
hostid = host['hostid']
|
||||
|
||||
# Get supply-related items
|
||||
items = self._apicall('item.get', {
|
||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
||||
'hostids': hostid,
|
||||
'search': {
|
||||
'key_': 'supply' # Common key pattern for printer supplies
|
||||
},
|
||||
'searchWildcardsEnabled': True
|
||||
items = self._apicall("item.get", {
|
||||
"output": ["itemid", "name", "lastvalue", "lastclock",
|
||||
"units", "status", "state"],
|
||||
"hostids": hostid,
|
||||
"selectTags": "extend",
|
||||
"evaltype": 0, # and
|
||||
"tags": self.SUPPLY_TAGS,
|
||||
"sortfield": "name",
|
||||
"monitored": True,
|
||||
})
|
||||
|
||||
if not items:
|
||||
# Try alternate patterns
|
||||
items = self._apicall('item.get', {
|
||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
||||
'hostids': hostid,
|
||||
'search': {
|
||||
'name': 'toner'
|
||||
},
|
||||
'searchWildcardsEnabled': True
|
||||
})
|
||||
|
||||
if not items:
|
||||
return []
|
||||
|
||||
supplies = []
|
||||
for item in items:
|
||||
# skip disabled or unsupported items
|
||||
if str(item.get("status", "0")) != "0":
|
||||
continue
|
||||
if str(item.get("state", "0")) != "0":
|
||||
continue
|
||||
try:
|
||||
level = int(float(item.get('lastvalue', 0)))
|
||||
level = int(float(item.get("lastvalue", 0)))
|
||||
except (ValueError, TypeError):
|
||||
level = 0
|
||||
|
||||
supplies.append({
|
||||
'name': item.get('name', 'Unknown'),
|
||||
'level': level,
|
||||
'itemid': item.get('itemid'),
|
||||
'key': item.get('key_'),
|
||||
"name": item.get("name", "Unknown"),
|
||||
"level": level,
|
||||
"color": self._extract_color(item),
|
||||
"itemid": item.get("itemid"),
|
||||
})
|
||||
|
||||
return supplies
|
||||
|
||||
def gethostid(self, ip: str) -> Optional[str]:
|
||||
"""Get Zabbix host ID for an IP address."""
|
||||
host = self.gethostbyip(ip)
|
||||
return host['hostid'] if host else None
|
||||
def getpingstatus(self, ip: str) -> str:
|
||||
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
return "-1"
|
||||
items = self._apicall("item.get", {
|
||||
"output": ["lastvalue"],
|
||||
"hostids": hostid,
|
||||
"search": {"key_": "icmpping"},
|
||||
})
|
||||
if items:
|
||||
return str(items[0].get("lastvalue", "-1"))
|
||||
return "-1"
|
||||
|
||||
# -- caching wrappers ----------------------------------------------------
|
||||
|
||||
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""Get printer supply levels with caching (10-minute TTL)."""
|
||||
cache_key = f'zabbix_supplies_{ip}'
|
||||
"""getsuppliesbyip with a 5-minute per-IP cache."""
|
||||
cache_key = f"zabbix_supplies_{ip}"
|
||||
result = cache.get(cache_key)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = self.getsuppliesbyip(ip)
|
||||
if result is not None:
|
||||
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
||||
return result
|
||||
|
||||
def clearcache(self, ip: str = None):
|
||||
"""Clear cached supply data for one IP or all."""
|
||||
"""Drop cached supply data for one IP, plus the low-supplies roll-up."""
|
||||
if ip:
|
||||
cache.delete(f'zabbix_supplies_{ip}')
|
||||
cache.delete('printers_low_supplies')
|
||||
cache.delete(f"zabbix_supplies_{ip}")
|
||||
cache.delete("printers_low_supplies")
|
||||
cache.delete("zabbix_reachable")
|
||||
|
||||
Reference in New Issue
Block a user