The toner report says what is empty now. It could not say what to order, and nothing recorded how fast anything drains - every level read was cached for five minutes and then discarded. Zabbix has been keeping the history all along; we simply never asked. One history.get gives both answers, because a cartridge only goes DOWN while it is in use: a rise is a replacement. Count the rises and you have how many cartridges a printer has been through; fit a slope to the readings SINCE the last rise and you have days-to-empty. Fitting across a replacement averages a spent cartridge with a fresh one and describes neither. Sorted by days left, which is the point. A cartridge at 60% dropping 5% a day needs ordering before one sitting at 8% that has not moved in months, and a level-sorted list ranks those backwards. It refuses to guess. Too few readings, a level that has not moved enough - many printers report in 10% steps and sit on a plateau for a fortnight - or a recent replacement each produce no estimate and say which. Those printers are listed separately rather than sorted in as 0 or as 999, since a printer without an estimate is neither urgent nor safe. Estimates show what they rest on, because "9 days from 21 days of readings" and "9 days from 2 readings" are not the same claim. A separate report card, not an extension of the toner report: that one is an exceptions list a tech acts on today, this is an ordering view read monthly, and the history query is heavier than the live read it would have slowed down. The analysis is pure arithmetic over a list of readings, so the 14 tests cover the noise wobble, the plateau, the swap, junk rows and division by zero without needing Zabbix. Zabbix being unreachable is reported as such rather than rendering an empty table that reads as "nothing is due".
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""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
|
|
import time
|
|
from typing import Dict, List, Optional
|
|
|
|
import requests
|
|
from flask import current_app
|
|
|
|
from shopdb.api import cache
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ZabbixService:
|
|
"""Zabbix API client for printer supply and ping lookups."""
|
|
|
|
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
|
REACHABLE_CHECK_TTL = 60
|
|
|
|
# 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)
|
|
|
|
# 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
|
|
|
|
# -- configuration -------------------------------------------------------
|
|
|
|
@property
|
|
def isenabled(self) -> bool:
|
|
"""Whether the integration is switched on."""
|
|
from shopdb.api import Setting
|
|
db_enabled = Setting.get('zabbix_enabled')
|
|
if db_enabled is not None:
|
|
return bool(db_enabled)
|
|
return current_app.config.get('ZABBIX_ENABLED', False)
|
|
|
|
@property
|
|
def isconfigured(self) -> bool:
|
|
"""Enabled, and a URL plus token are present."""
|
|
if not self.isenabled:
|
|
return False
|
|
from shopdb.api 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:
|
|
"""Cheap connectivity probe, cached for 60s."""
|
|
if not self.isconfigured:
|
|
return False
|
|
|
|
cache_key = 'zabbix_reachable'
|
|
cached = cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
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("Zabbix reachability: %s", reachable)
|
|
return reachable
|
|
|
|
# -- 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,
|
|
"id": 1,
|
|
}
|
|
headers = {
|
|
"Content-Type": "application/json-rpc",
|
|
"Authorization": f"Bearer {self._token}",
|
|
}
|
|
|
|
try:
|
|
response = requests.post(
|
|
self.endpoint,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=self.API_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
except (requests.RequestException, ValueError) as exc:
|
|
logger.error("Zabbix %s call failed: %s", method, exc)
|
|
return None
|
|
|
|
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].get("hostid")
|
|
return None
|
|
|
|
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"
|
|
|
|
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.
|
|
"""
|
|
hostid = self.gethostidbyip(ip)
|
|
if not hostid:
|
|
logger.debug("No Zabbix host for IP %s", ip)
|
|
return None
|
|
|
|
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:
|
|
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)))
|
|
except (ValueError, TypeError):
|
|
level = 0
|
|
supplies.append({
|
|
"name": item.get("name", "Unknown"),
|
|
"level": level,
|
|
"color": self._extract_color(item),
|
|
"itemid": item.get("itemid"),
|
|
})
|
|
return supplies
|
|
|
|
def gethistory(self, itemids, days=90, limit=5000):
|
|
"""Raw level history for supply items: {itemid: [(clock, value), ...]}.
|
|
|
|
Zabbix keeps this already - we simply never asked for it. history=3 is
|
|
the unsigned-integer table, which is where a percent-remaining item
|
|
lands; a site that types the item as float would need history=0, so a
|
|
miss falls back rather than erroring.
|
|
|
|
Returns {} when Zabbix is off or unreachable, so callers degrade to
|
|
"no estimate" instead of failing.
|
|
"""
|
|
if not itemids:
|
|
return {}
|
|
timefrom = int(time.time()) - days * 86400
|
|
collected = {}
|
|
for historytype in (3, 0):
|
|
rows = self._apicall("history.get", {
|
|
"output": "extend",
|
|
"history": historytype,
|
|
"itemids": list(itemids),
|
|
"time_from": timefrom,
|
|
"sortfield": "clock",
|
|
"sortorder": "ASC",
|
|
"limit": limit,
|
|
}) or []
|
|
for row in rows:
|
|
collected.setdefault(str(row.get("itemid")), []).append(
|
|
(row.get("clock"), row.get("value")))
|
|
# Items live in one table or the other; stop once something answered.
|
|
if collected:
|
|
break
|
|
return collected
|
|
|
|
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]]:
|
|
"""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):
|
|
"""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("zabbix_reachable")
|