417 lines
16 KiB
Python
417 lines
16 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
|
|
level, lastseen = self._readlevel(item)
|
|
supplies.append({
|
|
"name": item.get("name", "Unknown"),
|
|
"level": level,
|
|
"lastseen": lastseen,
|
|
"color": self._extract_color(item),
|
|
"itemid": item.get("itemid"),
|
|
})
|
|
return supplies
|
|
|
|
# How old the newest reading may be and still be presented as the level.
|
|
# Generous on purpose: supply items are polled in hours, not seconds, and
|
|
# calling a slow poller "unknown" would be its own lie. This exists for the
|
|
# host that stopped answering, not for one that answers rarely.
|
|
STALE_AFTER_SECONDS = 24 * 3600
|
|
|
|
@classmethod
|
|
def _readlevel(cls, item):
|
|
"""(level, lastseen) for one supply item; level is None when unknown.
|
|
|
|
An item that has never collected returns lastvalue as an EMPTY STRING,
|
|
and one whose host went quiet keeps a lastvalue that is arbitrarily old.
|
|
Both used to become 0 - the empty string because float('') raises and
|
|
the handler substituted zero, the stale one because nothing ever read
|
|
lastclock, which was fetched and then ignored.
|
|
|
|
Zero is not a safe stand-in for "no reading". It is a legitimate value
|
|
meaning the cartridge is spent, so the substitution was indistinguishable
|
|
from a real empty - and it hit every item on the host at once, which is
|
|
what a printer that has gone dark looks like. Downstream that reached
|
|
`level <= EMPTY_LEVEL`, so the forecast gave it daysleft 0 and the order
|
|
list asked purchasing to buy a full set of cartridges for a printer
|
|
nobody had measured.
|
|
|
|
None says "unknown" and every consumer already handles it: the detail
|
|
page renders N/A, the alert loop skips it, and analyse() falls back to
|
|
the last real level in history rather than inventing one.
|
|
"""
|
|
raw = item.get("lastvalue")
|
|
if raw is None or str(raw).strip() == "":
|
|
return None, None
|
|
try:
|
|
level = int(float(raw))
|
|
except (ValueError, TypeError):
|
|
return None, None
|
|
|
|
try:
|
|
lastseen = int(item.get("lastclock") or 0)
|
|
except (ValueError, TypeError):
|
|
lastseen = 0
|
|
if lastseen <= 0:
|
|
# A value with no clock cannot be aged. Trust the value and say the
|
|
# age is unknown rather than discarding a reading that may be fine.
|
|
return level, None
|
|
if time.time() - lastseen > cls.STALE_AFTER_SECONDS:
|
|
return None, lastseen
|
|
return level, lastseen
|
|
|
|
# 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")
|