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:
61
tools/docker-compose.zabbix.yml
Normal file
61
tools/docker-compose.zabbix.yml
Normal 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
144
tools/mock_zabbix.py
Normal 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()
|
||||
140
tools/setup_zabbix_fixture.py
Normal file
140
tools/setup_zabbix_fixture.py
Normal 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
55
tools/shot.py
Normal 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"])
|
||||
Reference in New Issue
Block a user