Files
shopdb-flask/plugins/printers/services/zabbix_service.py
cproudlock 7d66551622
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Forecast from the right end of the window, and from the level shown
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.
2026-08-13 10:54:23 -04:00

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")