Forecast from the right end of the window, and from the level shown
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

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.
This commit is contained in:
cproudlock
2026-08-13 10:54:23 -04:00
parent 1e884dc02a
commit 7d66551622
7 changed files with 376 additions and 25 deletions

View File

@@ -1541,16 +1541,18 @@ def supplies_forecast():
if not supplies:
continue
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
history = service.gethistory(itemids, days=days)
history = service.getlevelhistory(itemids, days=days)
analysed = []
for supply in supplies:
points = history.get(str(supply.get('itemid')), [])
detail = analyse(points)
# The live read is the level the report shows, so it is also the
# level the countdown is computed from - history lags a poll, and a
# row whose level and days-left came from different moments reads
# as broken.
detail = analyse(points, currentlevel=supply.get('level'))
detail['name'] = supply.get('name')
detail['color'] = supply.get('color')
# Trust the live read for the level; history can lag a poll behind.
detail['currentlevel'] = supply.get('level', detail['currentlevel'])
analysed.append(detail)
entry = {

View File

@@ -55,7 +55,14 @@
</thead>
<tbody>
<!-- Sorted by days left, not by level: a cartridge at 60% falling
fast is ordered before one sitting at 8% that never moves. -->
fast is ordered before one sitting at 8% that never moves.
Days left is per CARTRIDGE, not per printer. It used to span
the printer's rows, so every supply displayed the soonest one
of them - a cartridge at 20% sat beside "4 days" that belonged
to the black next to it. The printer number still decides
where the printer sorts; it does not label a row it is not
about. -->
<template v-for="p in printers" :key="p.printerid">
<tr v-for="(s, index) in p.supplies" :key="p.printerid + s.name"
:class="{ 'row-group-start': index === 0 }">
@@ -65,10 +72,12 @@
</router-link>
<div class="muted small">{{ p.ipaddress }}</div>
</td>
<td v-if="index === 0" :rowspan="p.supplies.length">
<span class="days" :class="urgency(p.daysleft)">
{{ p.daysleft }} days
<td>
<span v-if="s.daysleft === 0" class="days critical">empty now</span>
<span v-else-if="s.daysleft != null" class="days" :class="urgency(s.daysleft)">
{{ s.daysleft }} days
</span>
<span v-else class="muted">-</span>
</td>
<td>{{ s.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>

View File

@@ -29,6 +29,13 @@ MIN_POINTS_FOR_ESTIMATE = 4
# says "never", which is worse than saying nothing.
MIN_DROP_FOR_ESTIMATE = 2
# At or below this, the cartridge is done and the arithmetic stops being the
# useful answer. A supply sitting at 1% that drains a tenth of a point a day
# computes to ten days; a printer at 1% is out of toner as far as anyone
# standing at it is concerned, and it is what should be ordered first. Rate is
# still reported - only the days-left figure is floored.
EMPTY_LEVEL = 5
def _asfloat(value):
try:
@@ -101,11 +108,17 @@ def burn_rate(points):
return drop / days
def analyse(points, rise=REPLACEMENT_RISE):
def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
"""Everything the report needs about one supply.
`currentlevel` is the live reading from the printer, when the caller has
one. It is what the days-left figure is computed against, because it is
what the report displays: taking the level from the display and the
countdown from the last stored history point lets the two disagree, and a
row reading "20% - 4 days left" is read as a broken report, correctly.
Returns:
currentlevel latest reading, or None
currentlevel live reading if given, else the latest stored one
daysleft at the current rate, or None when there is no estimate
burnrateperday percent per day, or None
reason why there is no estimate - shown rather than hidden, so
@@ -117,12 +130,16 @@ def analyse(points, rise=REPLACEMENT_RISE):
"""
points = normalise(points)
result = {
'currentlevel': None, 'daysleft': None, 'burnrateperday': None,
'currentlevel': currentlevel, 'daysleft': None, 'burnrateperday': None,
'reason': None, 'replacements': 0, 'lastreplaced': None,
'basisdays': 0, 'points': [],
}
if not points:
result['reason'] = 'no history'
# A live level with no history is still worth acting on when it is low.
if currentlevel is not None and currentlevel <= EMPTY_LEVEL:
result['daysleft'] = 0
else:
result['reason'] = 'no history'
return result
replacements = find_replacements(points, rise=rise)
@@ -130,13 +147,30 @@ def analyse(points, rise=REPLACEMENT_RISE):
result['lastreplaced'] = replacements[-1].isoformat() if replacements else None
run = current_run(points, rise=rise)
result['currentlevel'] = run[-1][1]
stored = run[-1][1]
level = stored if currentlevel is None else currentlevel
result['currentlevel'] = level
result['points'] = [(when.isoformat(), level) for when, level in run]
if len(run) >= 2:
result['basisdays'] = round(
(run[-1][0] - run[0][0]).total_seconds() / 86400, 1)
# A live level well above the stored run means it was swapped since the
# last stored reading. The run describes the cartridge that came out.
if currentlevel is not None and currentlevel - stored >= rise:
result['replacements'] += 1
result['reason'] = 'replaced recently'
return result
rate = burn_rate(run)
# Empty is empty. Ordering by a rate below this level ranks a dead
# cartridge behind a healthy one that happens to be draining faster.
if level <= EMPTY_LEVEL:
result['daysleft'] = 0
result['burnrateperday'] = round(rate, 2) if rate is not None else None
return result
if rate is None:
# Say which of the three it is; "no estimate" alone invites a bug report.
if len(run) < MIN_POINTS_FOR_ESTIMATE:
@@ -147,7 +181,7 @@ def analyse(points, rise=REPLACEMENT_RISE):
return result
result['burnrateperday'] = round(rate, 2)
result['daysleft'] = max(0, int(run[-1][1] / rate))
result['daysleft'] = max(0, int(level / rate))
return result

View File

@@ -220,37 +220,119 @@ class ZabbixService:
})
return supplies
def gethistory(self, itemids, days=90, limit=5000):
# 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), ...]}.
Zabbix keeps this already - we simply never asked for it. history=3 is
the unsigned-integer table, which is where a percent-remaining item
lands; a site that types the item as float would need history=0, so a
miss falls back rather than erroring.
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": list(itemids),
"itemids": outstanding,
"time_from": timefrom,
"sortfield": "clock",
"sortorder": "ASC",
"limit": limit,
"sortorder": "DESC",
"limit": peritem * len(outstanding),
}) or []
for row in rows:
collected.setdefault(str(row.get("itemid")), []).append(
(row.get("clock"), row.get("value")))
# Items live in one table or the other; stop once something answered.
if collected:
break
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: