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:
cproudlock
2026-06-26 08:35:02 -04:00
parent 9d78b363dc
commit f1b3b65532
14 changed files with 1411 additions and 103 deletions

View File

@@ -0,0 +1,61 @@
# Real Zabbix 7.0 server for full integration testing.
#
# docker compose -f tools/docker-compose.zabbix.yml up -d
#
# Web UI: http://localhost:8888 (default login Admin / zabbix)
# API: http://localhost:8888/api_jsonrpc.php
#
# After it is up:
# 1. Log in, go to Users > API tokens, create a token, copy it.
# 2. Data collection > Hosts > Create host. Name the host by its IP
# (e.g. 10.20.30.40) so host.get filter {host:[ip]} finds it, the same
# way the production shop Zabbix names printer hosts.
# 3. Add items tagged component=supplies, type=level, color=<color> to
# mirror the real printer templates (the mock server documents the shape).
# 4. Point the app at it:
# ZABBIX_ENABLED=true
# ZABBIX_URL=http://localhost:8888
# ZABBIX_TOKEN=<token from step 1>
# or set zabbix_enabled / zabbix_url / zabbix_token in the settings table.
#
# For API-contract testing only, prefer tools/mock_zabbix.py - it needs no
# image pulls and returns ready-made tagged supply items.
services:
zabbix-postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: zabbixpass
POSTGRES_DB: zabbix
volumes:
- zabbix-pgdata:/var/lib/postgresql/data
zabbix-server:
image: zabbix/zabbix-server-pgsql:alpine-7.0-latest
environment:
DB_SERVER_HOST: zabbix-postgres
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: zabbixpass
POSTGRES_DB: zabbix
depends_on:
- zabbix-postgres
ports:
- "10051:10051"
zabbix-web:
image: zabbix/zabbix-web-nginx-pgsql:alpine-7.0-latest
environment:
DB_SERVER_HOST: zabbix-postgres
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: zabbixpass
POSTGRES_DB: zabbix
ZBX_SERVER_HOST: zabbix-server
PHP_TZ: America/New_York
depends_on:
- zabbix-server
ports:
- "8888:8080"
volumes:
zabbix-pgdata:

144
tools/mock_zabbix.py Normal file
View File

@@ -0,0 +1,144 @@
"""Mock Zabbix 7.0 JSON-RPC server for testing the printer supply integration.
Speaks just enough of the Zabbix API (api_jsonrpc.php) to exercise our
ZabbixService end to end over real HTTP: Bearer token auth, host.get by IP,
and item.get returning printer supply items tagged the way the real Zabbix
templates tag them (component=supplies, type=level, color=<color>).
Run standalone:
python tools/mock_zabbix.py --port 18080 --token testtoken
Then point the app at it:
ZABBIX_ENABLED=true
ZABBIX_URL=http://localhost:18080
ZABBIX_TOKEN=testtoken
It is also imported by tests/test_plugins/test_zabbix_live.py, which boots it
in a background thread.
"""
import argparse
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# one fake host, named by its IP (matches how the real shop Zabbix names hosts)
HOSTS = {
'10.20.30.40': '10501',
}
# supply level items for host 10501, with Zabbix-style tags
SUPPLY_ITEMS = {
'10501': [
{'itemid': '1', 'name': 'Black Toner Level', 'lastvalue': '4',
'status': '0', 'state': '0',
'tags': [{'tag': 'component', 'value': 'supplies'},
{'tag': 'type', 'value': 'level'},
{'tag': 'color', 'value': 'black'}]},
{'itemid': '2', 'name': 'Cyan Toner Level', 'lastvalue': '60',
'status': '0', 'state': '0',
'tags': [{'tag': 'component', 'value': 'supplies'},
{'tag': 'type', 'value': 'level'},
{'tag': 'color', 'value': 'cyan'}]},
{'itemid': '3', 'name': 'Waste Cartridge Level', 'lastvalue': '97',
'status': '0', 'state': '0',
'tags': [{'tag': 'component', 'value': 'supplies'},
{'tag': 'type', 'value': 'level'}]},
{'itemid': '4', 'name': 'Disabled Drum Level', 'lastvalue': '0',
'status': '1', 'state': '0',
'tags': [{'tag': 'component', 'value': 'supplies'},
{'tag': 'type', 'value': 'level'}]},
],
}
PING_ITEMS = {
'10501': '1',
}
def build_handler(token):
class ZabbixHandler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # quiet
def _send(self, payload, code=200):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
# reachability probe hits the endpoint with GET
self._send({'jsonrpc': '2.0', 'error': {'code': -32600}, 'id': None})
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
request = json.loads(self.rfile.read(length) or b'{}')
method = request.get('method')
params = request.get('params', {})
reqid = request.get('id', 1)
auth = self.headers.get('Authorization', '')
if auth != f'Bearer {token}':
self._send({'jsonrpc': '2.0',
'error': {'code': -32602, 'message': 'Not authorised'},
'id': reqid})
return
result = self._dispatch(method, params)
self._send({'jsonrpc': '2.0', 'result': result, 'id': reqid})
def _dispatch(self, method, params):
if method in ('apiinfo.version',):
return '7.0.0'
if method == 'hostgroup.get':
return [{'groupid': '1'}]
if method == 'host.get':
ips = (params.get('filter') or {}).get('host', [])
out = []
for ip in ips:
if ip in HOSTS:
out.append({'hostid': HOSTS[ip], 'host': ip, 'name': ip})
return out
if method == 'item.get':
hostids = params.get('hostids')
hostid = hostids[0] if isinstance(hostids, list) else hostids
search = params.get('search') or {}
if 'icmpping' in (search.get('key_') or ''):
value = PING_ITEMS.get(hostid)
return [{'lastvalue': value}] if value is not None else []
return SUPPLY_ITEMS.get(hostid, [])
return []
return ZabbixHandler
def serve(port, token):
server = ThreadingHTTPServer(('127.0.0.1', port), build_handler(token))
return server
def serve_in_thread(port, token):
"""Start the mock in a daemon thread. Returns the server (call shutdown())."""
server = serve(port, token)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Mock Zabbix JSON-RPC server')
parser.add_argument('--port', type=int, default=18080)
parser.add_argument('--token', default='testtoken')
args = parser.parse_args()
httpd = serve(args.port, args.token)
print(f"Mock Zabbix on http://127.0.0.1:{args.port}/api_jsonrpc.php "
f"(token: {args.token})")
print(f"Known host: {list(HOSTS)[0]} -> supplies + icmpping")
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()

View File

@@ -0,0 +1,140 @@
"""Provision the local docker Zabbix (tools/docker-compose.zabbix.yml) with a
printer host shaped like the real shop Zabbix, so ZabbixService can be tested
against a live server instead of the mock.
Creates:
- an API token (printed at the end; put it in .env as ZABBIX_TOKEN)
- host group "Printers"
- host named by IP "10.20.30.40" with an SNMP-less agent interface
- trapper items tagged component=supplies, type=level, color=<c>
- an icmpping item
Item values are pushed afterwards with zabbix_sender (see seed_values()).
Run: python tools/setup_zabbix_fixture.py
"""
import sys
import requests
BASE = "http://localhost:8888/api_jsonrpc.php"
HOST_IP = "10.20.30.40"
ADMIN_USER = "Admin"
ADMIN_PASS = "zabbix"
# name, color tag, key (must be unique per host)
SUPPLY_ITEMS = [
("Black Toner Level", "black", "supply.black"),
("Cyan Toner Level", "cyan", "supply.cyan"),
("Magenta Toner Level", "magenta", "supply.magenta"),
("Yellow Toner Level", "yellow", "supply.yellow"),
("Waste Cartridge Level", "", "supply.waste"),
]
def call(method, params, auth=None):
headers = {"Content-Type": "application/json-rpc"}
if auth:
headers["Authorization"] = f"Bearer {auth}"
resp = requests.post(
BASE,
json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1},
headers=headers,
timeout=10,
)
resp.raise_for_status()
data = resp.json()
if "error" in data:
raise RuntimeError(f"{method}: {data['error']}")
return data["result"]
def login():
# user.login returns a session token; usable as Bearer in 7.0
return call("user.login", {"username": ADMIN_USER, "password": ADMIN_PASS})
def make_api_token(sess):
# a real, persistent API token (survives logout, matches prod usage)
existing = call("token.get", {"filter": {"name": "shopdb-flask-test"}}, sess)
if existing:
tokenid = existing[0]["tokenid"]
else:
created = call("token.create", {
"name": "shopdb-flask-test",
"userid": call("user.get", {"output": ["userid"],
"filter": {"username": ADMIN_USER}}, sess)[0]["userid"],
}, sess)
tokenid = created["tokenids"][0]
return call("token.generate", [tokenid], sess)[0]["token"]
def ensure_hostgroup(sess):
found = call("hostgroup.get", {"filter": {"name": "Printers"}}, sess)
if found:
return found[0]["groupid"]
return call("hostgroup.create", {"name": "Printers"}, sess)["groupids"][0]
def ensure_host(sess, groupid):
found = call("host.get", {"filter": {"host": [HOST_IP]}, "output": ["hostid"]}, sess)
if found:
hostid = found[0]["hostid"]
# wipe existing items so re-runs are clean
items = call("item.get", {"hostids": hostid, "output": ["itemid"]}, sess)
if items:
call("item.delete", [i["itemid"] for i in items], sess)
return hostid
created = call("host.create", {
"host": HOST_IP,
"groups": [{"groupid": groupid}],
"interfaces": [{
"type": 1, "main": 1, "useip": 1,
"ip": HOST_IP, "dns": "", "port": "10050",
}],
}, sess)
return created["hostids"][0]
def create_items(sess, hostid):
for name, color, key in SUPPLY_ITEMS:
tags = [
{"tag": "component", "value": "supplies"},
{"tag": "type", "value": "level"},
]
if color:
tags.append({"tag": "color", "value": color})
call("item.create", {
"name": name,
"key_": key,
"hostid": hostid,
"type": 2, # Zabbix trapper, lets zabbix_sender push values
"value_type": 3, # unsigned int
"tags": tags,
}, sess)
# ping item, untagged, key icmpping
call("item.create", {
"name": "ICMP ping",
"key_": "icmpping",
"hostid": hostid,
"type": 2,
"value_type": 3,
}, sess)
def main():
sess = login()
token = make_api_token(sess)
groupid = ensure_hostgroup(sess)
hostid = ensure_host(sess, groupid)
create_items(sess, hostid)
print("OK")
print(f"hostid={hostid}")
print(f"ZABBIX_TOKEN={token}")
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"FAILED: {exc}", file=sys.stderr)
sys.exit(1)

55
tools/shot.py Normal file
View File

@@ -0,0 +1,55 @@
"""Headless-Chromium screenshot helper for the dev UI.
Logs in once via the API, injects the token into localStorage the same way
the auth store does, then screenshots each path passed on the command line.
venv/bin/python tools/shot.py /printers/1 /reports/toner /printers/1/edit
Images land in the scratchpad dir as shot_<sanitised-path>.png.
"""
import sys
import json
import urllib.request
from playwright.sync_api import sync_playwright
UI = "http://localhost:5173"
API = "http://localhost:5001/api"
USERNAME = "270015376"
PASSWORD = "changeme"
OUTDIR = "/tmp/claude-1000/-home-camp-projects/effc3424-ed5e-4b09-b83e-d141bee23c42/scratchpad"
def login():
body = json.dumps({"username": USERNAME, "password": PASSWORD}).encode()
loginrequest = urllib.request.Request(f"{API}/auth/login", data=body,
headers={"Content-Type": "application/json"})
data = json.load(urllib.request.urlopen(loginrequest))["data"]
return data["access_token"], data["refresh_token"], data["user"]
def main(paths):
token, refresh, user = login()
seed = f"""
localStorage.setItem('token', {json.dumps(token)});
localStorage.setItem('refreshToken', {json.dumps(refresh)});
localStorage.setItem('user', {json.dumps(json.dumps(user))});
"""
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context(viewport={"width": 1400, "height": 1000})
context.add_init_script(seed)
page = context.new_page()
for path in paths:
page.goto(f"{UI}{path}", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(1200) # let supply fetch + render settle
name = "shot_" + (path.strip("/").replace("/", "_") or "home") + ".png"
out = f"{OUTDIR}/{name}"
page.screenshot(path=out, full_page=True)
print(out)
browser.close()
if __name__ == "__main__":
main(sys.argv[1:] or ["/printers/1"])