A supply nobody could read is unknown, not empty
This commit is contained in:
@@ -2109,7 +2109,9 @@ def _annotate_supply(supply, vendor_name, modelnumberid):
|
||||
Waste cartridge direction depends on vendor, so classification lives in
|
||||
the supply_parts helper. Part numbers come from the modelsupplies table.
|
||||
"""
|
||||
level = supply.get('level', 0)
|
||||
# .get('level') with no default: a supply with no reading carries None, and
|
||||
# a 0 default would put back the very substitution this reports around.
|
||||
level = supply.get('level')
|
||||
name = supply.get('name', 'Unknown')
|
||||
supplytype = derivesupplytype(name)
|
||||
color = derivecolor(name, supply.get('color'))
|
||||
@@ -2117,6 +2119,7 @@ def _annotate_supply(supply, vendor_name, modelnumberid):
|
||||
return {
|
||||
'name': name,
|
||||
'level': level,
|
||||
'lastseen': supply.get('lastseen'),
|
||||
'color': color,
|
||||
'supplytype': supplytype,
|
||||
'status': cls['status'],
|
||||
@@ -2160,6 +2163,7 @@ def _get_low_supplies_data():
|
||||
|
||||
results = []
|
||||
total_checked = 0
|
||||
unknown_printers = 0
|
||||
|
||||
for printer, asset, comm, vendor, model in unique_printers:
|
||||
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
|
||||
@@ -2178,13 +2182,24 @@ def _get_low_supplies_data():
|
||||
# shown it. The whole report exists to answer "what needs replacing".
|
||||
annotated = []
|
||||
has_low = False
|
||||
has_unknown = False
|
||||
for s in supplies:
|
||||
item = _annotate_supply(s, vendor_name, modelnumberid)
|
||||
if item['status'] == 'ok':
|
||||
continue
|
||||
# A supply nobody could read is not a supply that needs replacing,
|
||||
# so it does not join the list of things to act on. It is counted
|
||||
# instead: a printer that has gone quiet should be visible as that,
|
||||
# not disappear from the report and not masquerade as empty.
|
||||
if item['status'] == 'unknown':
|
||||
has_unknown = True
|
||||
continue
|
||||
has_low = True
|
||||
annotated.append(item)
|
||||
|
||||
if has_unknown:
|
||||
unknown_printers += 1
|
||||
|
||||
if has_low:
|
||||
# location name for the report row
|
||||
# Via the relationship, the way the printers list does it. The
|
||||
@@ -2222,7 +2237,12 @@ def _get_low_supplies_data():
|
||||
'summary': {
|
||||
'total_checked': total_checked,
|
||||
'low': low_count,
|
||||
'critical': critical_count
|
||||
'critical': critical_count,
|
||||
# Printers with at least one unreadable supply. They are NOT in
|
||||
# 'printers' above - nothing about them needs ordering - but a
|
||||
# count that only ever went up when a cartridge ran low would hide
|
||||
# a fleet quietly going dark.
|
||||
'unknown': unknown_printers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,8 +215,14 @@
|
||||
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
|
||||
<div class="supply-header">
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<span class="supply-level" :class="supply.status">
|
||||
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
|
||||
<!-- N/A is not the same as 0%. A supply Zabbix has no usable
|
||||
reading for arrives with level null and status 'unknown';
|
||||
the title says which of the two reasons it is, because
|
||||
"no data" and "last seen three weeks ago" need different
|
||||
people to fix them. -->
|
||||
<span class="supply-level" :class="supply.status"
|
||||
:title="supply.status === 'unknown' ? unknownReason(supply) : null">
|
||||
{{ supply.level !== null && supply.level !== undefined ? `${supply.level}%` : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="supply-bar">
|
||||
@@ -227,7 +233,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<div class="supply-meta">
|
||||
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
|
||||
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste && supply.remaining !== null"> ({{ supply.remaining }}% remaining)</template></span>
|
||||
<span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
@@ -363,6 +369,15 @@ function formatSupplyType(supplytype) {
|
||||
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
|
||||
}
|
||||
|
||||
// Why a supply reads N/A. lastseen is the epoch of the newest reading Zabbix
|
||||
// holds: absent means the item has never collected, present means it has but
|
||||
// the reading is too old to present as current. Those are different faults and
|
||||
// the tooltip is the only place either one is stated.
|
||||
function unknownReason(supply) {
|
||||
if (!supply.lastseen) return 'Zabbix has no reading for this supply'
|
||||
return `No reading since ${new Date(supply.lastseen * 1000).toLocaleString()}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
@@ -414,6 +429,10 @@ function formatDate(dateStr) {
|
||||
.supply-level.ok { color: var(--success); }
|
||||
.supply-level.low { color: var(--warning); }
|
||||
.supply-level.critical { color: var(--danger); }
|
||||
/* Muted on purpose. An unknown level is not a severity - colouring it like one
|
||||
would put it in the same reading as critical, which is the confusion this
|
||||
whole change exists to end. The help cursor points at the tooltip. */
|
||||
.supply-level.unknown { color: var(--text-light); cursor: help; }
|
||||
|
||||
.supply-bar {
|
||||
height: 10px;
|
||||
|
||||
@@ -323,7 +323,9 @@ class PrintersPlugin(BasePlugin):
|
||||
|
||||
click.echo(f'Supply levels for {ip}:')
|
||||
for supply in supplies:
|
||||
click.echo(f" {supply['name']}: {supply['level']}%")
|
||||
level = supply.get('level')
|
||||
shown = 'unknown' if level is None else f'{level}%'
|
||||
click.echo(f" {supply['name']}: {shown}")
|
||||
|
||||
@printerscli.command('seed-supplies')
|
||||
def seedsuppliescommand():
|
||||
|
||||
@@ -68,19 +68,34 @@ def derivecolor(name: str, tagcolor: Optional[str] = None) -> str:
|
||||
return "none"
|
||||
|
||||
|
||||
def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict:
|
||||
"""Classify one supply item into ok/low/critical.
|
||||
def classifysupply(level: Optional[float], name: str,
|
||||
vendor: Optional[str]) -> Dict:
|
||||
"""Classify one supply item into ok/low/critical, or unknown.
|
||||
|
||||
Waste cartridge fill is inverted vs a toner level: a full waste cartridge
|
||||
is bad. Standard vendors report waste as percent FULL (high = bad). Xerox
|
||||
EC/AltaLink series report waste as percent capacity REMAINING (low = bad),
|
||||
same direction as toner. Normalise everything to percent remaining first.
|
||||
|
||||
A level of None means Zabbix has no usable reading; the status is 'unknown'
|
||||
and remaining is None. Callers must not treat that as a low supply.
|
||||
"""
|
||||
lowername = (name or "").lower()
|
||||
iswaste = "waste" in lowername
|
||||
isdrum = "drum" in lowername or "imaging" in lowername
|
||||
isxerox = bool(vendor) and "xerox" in vendor.lower()
|
||||
|
||||
# No reading is its own answer, not a level. It cannot be classified ok,
|
||||
# low or critical without asserting something nobody measured - and every
|
||||
# one of those three is a claim the reader would act on.
|
||||
if level is None:
|
||||
return {
|
||||
"status": "unknown",
|
||||
"remaining": None,
|
||||
"iswaste": iswaste,
|
||||
"isdrum": isdrum,
|
||||
}
|
||||
|
||||
if iswaste and not isxerox:
|
||||
remaining = 100 - level
|
||||
else:
|
||||
|
||||
@@ -208,18 +208,64 @@ class ZabbixService:
|
||||
continue
|
||||
if str(item.get("state", "0")) != "0":
|
||||
continue
|
||||
try:
|
||||
level = int(float(item.get("lastvalue", 0)))
|
||||
except (ValueError, TypeError):
|
||||
level = 0
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user