Four defects stacked into one nonsense report: cartridges at 20% claiming four days, cartridges at 1% claiming weeks. The root cause is a Zabbix API detail. `limit` caps the whole result set rather than each item, and the query sorted ascending, so the cap kept the OLDEST rows in the window. A four-cartridge printer polled every five minutes writes over 100k readings in 90 days; the forecast was fitted to the first few days of that and nothing since. Every rate was real and every rate described a cartridge thrown away three months ago. Nothing in the output looks wrong, which is why it needed pinning in a test rather than a comment. A 90-day burn rate does not need every individual poll, so a long window now reads hourly trends - the table meant for this, a tenth of the rows, and kept longer. Raw history serves short windows and any item a site keeps no trends for. Both are fetched newest-first with the budget scaled per item. Second, the countdown was computed from the last stored reading while the level displayed was the live one, so the two could disagree by a whole cartridge. The live level is now what the countdown divides. A live level far above the stored run means it was swapped since the last reading, and that is reported as a replacement rather than as a collapse in the burn rate. Third, at or below 5% a cartridge reads as empty rather than as a slow drain. At 1% losing a tenth of a point a day the arithmetic says ten days. The printer is out of toner, and it is the first thing to order. Fourth, the days-left column spanned the printer's rows, so the printer's soonest figure was printed beside every supply it had. That alone accounts for the shape of both complaints: a healthy cartridge wearing its neighbour's deadline, and an empty one wearing a number that belonged to nothing on its row. Also fixes float-typed supplies vanishing from any printer that also had an integer-typed one - they live in different history tables and the fetch stopped at whichever answered first. Not verified against live data: Zabbix is not reachable from the dev box.
371 lines
14 KiB
Python
371 lines
14 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
|
|
|
|
# Per item, per call. Zabbix applies `limit` to the WHOLE result set, not
|
|
# per item, so the budget is multiplied by the number of items asked for.
|
|
HISTORY_PER_ITEM = 2000
|
|
|
|
def gethistory(self, itemids, days=90, peritem=None):
|
|
"""Raw level history for supply items: {itemid: [(clock, value), ...]}.
|
|
|
|
Ordered oldest-first, which is what the analysis expects.
|
|
|
|
Two things about the Zabbix API make the obvious call return the wrong
|
|
rows, and both bit us:
|
|
|
|
* `limit` caps the whole answer, not each item. A four-cartridge
|
|
printer polled every five minutes writes over 100k rows in 90 days.
|
|
* combined with `sortorder: ASC`, the cap keeps the OLDEST rows - so a
|
|
"90 day" request came back holding the first few days of the window
|
|
and nothing since. Every rate was computed from history a quarter of
|
|
a year stale.
|
|
|
|
So: sort DESC to keep the NEWEST rows, scale the budget by item count,
|
|
and hand back ascending.
|
|
|
|
history=3 is the unsigned-integer table, where a percent-remaining item
|
|
lands; a site that types the item as float needs history=0. Items can be
|
|
split across the two, so both are asked and the results merged - an
|
|
earlier version stopped as soon as either answered, which silently lost
|
|
every float-typed supply on a printer that also had an integer one.
|
|
|
|
Returns {} when Zabbix is off or unreachable, so callers degrade to
|
|
"no estimate" instead of failing.
|
|
"""
|
|
if not itemids:
|
|
return {}
|
|
itemids = [str(i) for i in itemids]
|
|
peritem = peritem or self.HISTORY_PER_ITEM
|
|
timefrom = int(time.time()) - days * 86400
|
|
collected = {}
|
|
|
|
for historytype in (3, 0):
|
|
outstanding = [i for i in itemids if i not in collected]
|
|
if not outstanding:
|
|
break
|
|
rows = self._apicall("history.get", {
|
|
"output": "extend",
|
|
"history": historytype,
|
|
"itemids": outstanding,
|
|
"time_from": timefrom,
|
|
"sortfield": "clock",
|
|
"sortorder": "DESC",
|
|
"limit": peritem * len(outstanding),
|
|
}) or []
|
|
for row in rows:
|
|
collected.setdefault(str(row.get("itemid")), []).append(
|
|
(row.get("clock"), row.get("value")))
|
|
|
|
for itemid, points in collected.items():
|
|
points.reverse() # DESC came back; analysis wants ASC
|
|
return collected
|
|
|
|
# Trends are hourly aggregates and are kept far longer than raw history.
|
|
# 90 days of raw 5-minute readings is 26k rows an item; the same window in
|
|
# trends is 2160. For a burn rate over months that is the right table -
|
|
# nothing is gained by fitting a line through every individual poll.
|
|
TRENDS_MIN_DAYS = 7
|
|
TRENDS_PER_ITEM = 3000
|
|
|
|
def gettrends(self, itemids, days=90, peritem=None):
|
|
"""Hourly level averages: {itemid: [(clock, value), ...]}, oldest first.
|
|
|
|
Same `limit` caveat as gethistory - it caps the whole answer, so the
|
|
budget is scaled and the sort is DESC.
|
|
|
|
Returns {} if trends are not kept (a site can set the trend period to
|
|
0), which is a real configuration, not an error. Callers fall back to
|
|
raw history.
|
|
"""
|
|
if not itemids:
|
|
return {}
|
|
itemids = [str(i) for i in itemids]
|
|
peritem = peritem or self.TRENDS_PER_ITEM
|
|
timefrom = int(time.time()) - days * 86400
|
|
rows = self._apicall("trend.get", {
|
|
"output": ["itemid", "clock", "value_avg"],
|
|
"itemids": itemids,
|
|
"time_from": timefrom,
|
|
"sortfield": "clock",
|
|
"sortorder": "DESC",
|
|
"limit": peritem * len(itemids),
|
|
}) or []
|
|
|
|
collected = {}
|
|
for row in rows:
|
|
collected.setdefault(str(row.get("itemid")), []).append(
|
|
(row.get("clock"), row.get("value_avg")))
|
|
for itemid, points in collected.items():
|
|
points.reverse()
|
|
return collected
|
|
|
|
def getlevelhistory(self, itemids, days=90):
|
|
"""Level history for a forecast, from whichever table serves it best.
|
|
|
|
Trends for a long window, raw history for a short one, and raw history
|
|
for any item the trend table has nothing for.
|
|
"""
|
|
if not itemids:
|
|
return {}
|
|
if days < self.TRENDS_MIN_DAYS:
|
|
return self.gethistory(itemids, days=days)
|
|
|
|
collected = self.gettrends(itemids, days=days)
|
|
missing = [i for i in (str(i) for i in itemids) if not collected.get(i)]
|
|
if missing:
|
|
collected.update(self.gethistory(missing, days=days))
|
|
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")
|