Shadow task fixes, asset link ids, backup verdict

This commit is contained in:
2026-08-13 13:20:24 -04:00
parent 6e0d8aec4e
commit 376b8df0fb
10 changed files with 1036 additions and 150 deletions

View File

@@ -77,6 +77,19 @@ that consumed it. The theme of the release is making those failures visible:
each fix ships with the check, report or document that would have surfaced it. each fix ships with the check, report or document that would have surfaced it.
### Changed
- The toner forecast is read as an order, not as a table. It opens with what to
buy - grouped by part number, with a quantity - because two cartridges of the
same part in different printers is a quantity of two, and that was a number
the reader had to work out by hand. Below it, cartridges sit in urgency bands
(empty / two weeks / thirty days / later) rather than in one long sortable
list, since the question being asked is which pile a thing is in. A row is
now a cartridge rather than a printer, carries its own part number and a
level bar, and everything past "empty" starts collapsed. Cartridges with no
part mapped are counted on one line instead of a line each: they still have
to be ordered, but not from this page - the job is to map them.
### Fixed ### Fixed
- The toner forecast was reading the wrong end of the window. Zabbix applies a - The toner forecast was reading the wrong end of the window. Zabbix applies a

View File

@@ -955,9 +955,12 @@ def _asset_facts(hostnames):
'mapx': asset.mapx, 'mapx': asset.mapx,
'mapy': asset.mapy, 'mapy': asset.mapy,
'machinenumber': None, 'machineassetid': None, 'machinenumber': None, 'machineassetid': None,
'machinepluginid': None,
'toolassetnumber': None, 'toolassetid': None, 'toolassetnumber': None, 'toolassetid': None,
'toolpluginid': None,
'displayrole': None, 'displayrole': None,
'backupkind': None, 'backuplastseen': None, 'backupkind': None, 'backuplastseen': None,
'backuplastseenat': None,
} }
_attach_controlled_assets(facts, assetidtohost) _attach_controlled_assets(facts, assetidtohost)
@@ -993,11 +996,24 @@ def _attach_controlled_assets(facts, assetidtohost):
AssetRelationship.sourceassetid.in_(assetidtohost)) AssetRelationship.sourceassetid.in_(assetidtohost))
.all()) .all())
toolassetids = set() # assetid -> the PLUGIN extension id the detail routes key on. /machines/:id
# and /measuringtools/:id take machineid / measuringtoolid, NOT the core
# assetid; linking with the assetid lands on whichever record happens to
# share that number, which looks plausible and is wrong. Frontend
# getAssetRoute resolves the same way.
toolids = {}
try: try:
from plugins.measuringtools.models import MeasuringTool from plugins.measuringtools.models import MeasuringTool
toolassetids = {row.assetid for row in toolids = {row.assetid: row.measuringtoolid for row in
MeasuringTool.query.with_entities(MeasuringTool.assetid)} MeasuringTool.query.with_entities(
MeasuringTool.assetid, MeasuringTool.measuringtoolid)}
except ImportError:
pass
machineids = {}
try:
from plugins.machines.models import Machine
machineids = {row.assetid: row.machineid for row in
Machine.query.with_entities(Machine.assetid, Machine.machineid)}
except ImportError: except ImportError:
pass pass
@@ -1005,12 +1021,17 @@ def _attach_controlled_assets(facts, assetidtohost):
entry = facts.get(assetidtohost.get(sourceassetid)) entry = facts.get(assetidtohost.get(sourceassetid))
if not entry: if not entry:
continue continue
if asset.assetid in toolassetids: if asset.assetid in toolids:
entry['toolassetnumber'] = asset.assetnumber entry['toolassetnumber'] = asset.assetnumber
entry['toolassetid'] = asset.assetid entry['toolassetid'] = asset.assetid
entry['toolpluginid'] = toolids.get(asset.assetid)
else: else:
entry['machinenumber'] = asset.assetnumber entry['machinenumber'] = asset.assetnumber
entry['machineassetid'] = asset.assetid entry['machineassetid'] = asset.assetid
# None when the machines plugin is not installed or the asset has no
# extension row: the frontend then renders the number without a link
# rather than a link that goes somewhere wrong.
entry['machinepluginid'] = machineids.get(asset.assetid)
def _attach_display_roles(facts, hostnames): def _attach_display_roles(facts, hostnames):
@@ -1059,6 +1080,51 @@ def _attach_backup_state(facts, hostnames):
entry['backuplastseen'] = ( entry['backuplastseen'] = (
revision.lastseenat.isoformat() + 'Z' revision.lastseenat.isoformat() + 'Z'
if revision.lastseenat else None) if revision.lastseenat else None)
# Raw value kept beside the wire string so the verdict compares
# datetimes rather than re-parsing its own output.
entry['backuplastseenat'] = revision.lastseenat
def _backup_ok(lastseenat, cutoff):
"""Tri-state verdict for one PC's backup: True good, False stale, None unknown.
A BOOLEAN rather than a date, because the question is "is this backed up?"
and every backup kind answers it the same way. Adding a new kind needs no
change here - it reports a revision like the others and inherits the badge.
None means there is nothing to judge: no backup revision for this host at
all, or the staleness check is disabled. The caller shows no badge rather
than a green one, since "never seen" must not read as healthy.
"""
if cutoff is None or lastseenat is None:
return None
return lastseenat >= cutoff
def _backup_stale_cutoff():
"""(cutoff datetime, threshold days) for 'this PC's backup has gone quiet'.
A raw date makes the reader do the arithmetic, and the arithmetic is not
obvious: the collector only ATTEMPTS every backups_intervalhours (24 by
default), so a confirmation from yesterday is the healthy steady state, not
a warning. What answers the question is the verdict.
The threshold is the backups plugin's own backups_staledays, read through
its service so there is ONE definition of stale rather than a second one
drifting here. Returns (None, 0) when the plugin is absent or the check is
disabled, and callers then show the date alone.
"""
try:
from plugins.backups.services import staleness
except ImportError:
return None, 0
try:
days = int(staleness.staledays())
except (TypeError, ValueError):
return None, 0
if days <= 0:
return None, 0
return service._utcnow() - timedelta(days=days), days
def _report_stale_cutoff(): def _report_stale_cutoff():
@@ -1108,6 +1174,7 @@ def list_reports():
if report.hostname}) if report.hostname})
stalecutoff, stalafter = _report_stale_cutoff() stalecutoff, stalafter = _report_stale_cutoff()
backupcutoff, backupafterdays = _backup_stale_cutoff()
latest_cache = {} latest_cache = {}
data = [] data = []
for report in reports: for report in reports:
@@ -1142,13 +1209,23 @@ def list_reports():
'mapy': known.get('mapy'), 'mapy': known.get('mapy'),
'machinenumber': known.get('machinenumber'), 'machinenumber': known.get('machinenumber'),
'machineassetid': known.get('machineassetid'), 'machineassetid': known.get('machineassetid'),
'machinepluginid': known.get('machinepluginid'),
'toolassetnumber': known.get('toolassetnumber'), 'toolassetnumber': known.get('toolassetnumber'),
'toolassetid': known.get('toolassetid'), 'toolassetid': known.get('toolassetid'),
'toolpluginid': known.get('toolpluginid'),
# Reported by the device wins; the DashboardDefault mapping is a # Reported by the device wins; the DashboardDefault mapping is a
# fallback for hosts on an older client that does not send it. # fallback for hosts on an older client that does not send it.
'displayrole': report.subtype or known.get('displayrole'), 'displayrole': report.subtype or known.get('displayrole'),
'backupkind': known.get('backupkind'), 'backupkind': known.get('backupkind'),
'backuplastseen': known.get('backuplastseen'), 'backuplastseen': known.get('backuplastseen'),
# VERIFIED, not "last backup taken". Dedup means an unchanged config
# writes no new revision, so lastseenat is the last time the
# collector CHECKED and found the backup intact - which is the
# question. backupok says whether that check is recent enough to
# trust; the date alone reads as "nothing has happened since", which
# is the healthy steady state and looks like neglect.
'backupok': _backup_ok(known.get('backuplastseenat'), backupcutoff),
'backupstaleafterdays': backupafterdays,
'scopename': report.scopename, 'scopename': report.scopename,
'phase': report.phase, 'phase': report.phase,
'appliedversion': report.appliedversion, 'appliedversion': report.appliedversion,

View File

@@ -20,12 +20,28 @@ $ErrorActionPreference = 'Continue'
$TaskName = 'ShopDB GE-Enforce (shadow)' $TaskName = 'ShopDB GE-Enforce (shadow)'
$InstallDir = 'C:\Program Files\GE\Shopfloor' $InstallDir = 'C:\Program Files\GE\Shopfloor'
$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' $BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
$Scope = 'gea-shopfloor-nocollections' # Scope is NOT hardcoded: this script is shipped by more than one scope
# (collections and nocollections today), and a wrong value here would shadow the
# wrong manifest silently. It runs from <drive>:\<scope>\shopdb-client, so the
# directory it sits under IS the scope name - the same derivation used below for
# the share manifest, so the two cannot disagree.
$Scope = Split-Path -Leaf (Split-Path -Parent $PSScriptRoot)
function Write-ShadowLog { function Write-ShadowLog {
# Write-Host ALONE is not enough: the engine records only "ps1: <path>" and
# the exit code for a PS1 entry, so nothing this script says reaches the
# enforce log. Combined with the fail-safe `exit 0` on every path, a silent
# early-out was indistinguishable from success - which is exactly how the
# never-firing task went unnoticed. Write to a file as well so the next
# failure is answerable from disk.
param([string]$Message) param([string]$Message)
$line = "[{0}] [shadow-setup] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message $line = "[{0}] [shadow-setup] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Host $line Write-Host $line
try {
$dir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
Add-Content -LiteralPath (Join-Path $dir ('shadow-setup-{0}.log' -f (Get-Date -Format yyyyMMdd))) -Value $line
} catch { }
} }
try { try {
@@ -60,6 +76,37 @@ try {
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Scope "{1}" -EnginePath "{2}" -ShadowMode -ShareManifestPath "{3}"' ` $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Scope "{1}" -EnginePath "{2}" -ShadowMode -ShareManifestPath "{3}"' `
-f $runner, $Scope, $engine, $shareManifest -f $runner, $Scope, $engine, $shareManifest
# ONLY register when it is missing or its arguments changed.
#
# Registering unconditionally is what stopped this working the first time.
# A `-Once -At (Get-Date)` trigger does NOT fire immediately: the first run
# is start-boundary PLUS the repetition interval, so 15 minutes out. This
# entry is DetectionMethod=Always and runs every enforce cycle, 5 minutes
# apart, and each Register-ScheduledTask -Force reset the start boundary to
# "now" - pushing the first run back to +15 before the previous +15 could
# elapse. 5 < 15, so the task sat at Ready with LastTaskResult 267011
# (SCHED_S_TASK_HAS_NOT_RUN) indefinitely. Measured on the win11 VM.
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($existing) {
$currentArgs = ($existing.Actions | Select-Object -First 1).Arguments
if ($currentArgs -eq $arguments) {
# Correct arguments, but a bay provisioned by the BROKEN version of
# this script carries a task that was reset every cycle and so has
# never run. Leaving it alone would strand exactly the bays that hit
# the bug. Kick it once; after that LastRunTime is set and this is a
# no-op forever.
$info = $existing | Get-ScheduledTaskInfo
$neverran = ($null -eq $info.LastRunTime) -or
($info.LastRunTime -lt (Get-Date '2000-01-01'))
if ($neverran) {
Write-ShadowLog ("task exists but has never run (LastTaskResult $($info.LastTaskResult)) - starting it once.")
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
return # schedule is correct; do not reset the start boundary
}
Write-ShadowLog 'task arguments changed - re-registering.'
}
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
# RepetitionInterval ALONE - passing RepetitionDuration serializes to a # RepetitionInterval ALONE - passing RepetitionDuration serializes to a
# Duration the Task Scheduler schema rejects. # Duration the Task Scheduler schema rejects.
@@ -69,6 +116,9 @@ try {
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null -Principal $principal -Settings $settings -Force | Out-Null
# Kick it once rather than waiting out the first 15-minute interval, so a
# freshly provisioned bay reports on this cycle instead of the next.
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Write-ShadowLog "registered '$TaskName' (every 15 min), shadowing $shareManifest" Write-ShadowLog "registered '$TaskName' (every 15 min), shadowing $shareManifest"
exit 0 exit 0
} }

View File

@@ -61,23 +61,33 @@
<!-- What this PC IS or DRIVES, whichever applies. A part-marker <!-- What this PC IS or DRIVES, whichever applies. A part-marker
or bay PC shows its machine; a measuring-tool PC its tool; or bay PC shows its machine; a measuring-tool PC its tool;
a display its role. --> a display its role. -->
<router-link v-if="report.machineassetid" class="asset-chip" <!-- /machines/:id and /measuringtools/:id key on the PLUGIN
:to="`/machines/${report.machineassetid}`" extension id, NOT the core assetid. Linking with the assetid
lands on whichever record happens to share that number - a
wrong page that looks right. No plugin id means the plugin
is not installed or the asset has no extension row, so show
the number as plain text rather than a link that misleads. -->
<router-link v-if="report.machinepluginid" class="asset-chip"
:to="`/machines/${report.machinepluginid}`"
title="Machine this PC controls"> title="Machine this PC controls">
{{ report.machinenumber }} {{ report.machinenumber }}
</router-link> </router-link>
<router-link v-if="report.toolassetid" class="asset-chip" <span v-else-if="report.machinenumber" class="asset-chip muted"
:to="`/measuringtools/${report.toolassetid}`" title="Machine this PC controls">{{ report.machinenumber }}</span>
<router-link v-if="report.toolpluginid" class="asset-chip"
:to="`/measuringtools/${report.toolpluginid}`"
title="Measuring tool this PC controls"> title="Measuring tool this PC controls">
{{ report.toolassetnumber }} {{ report.toolassetnumber }}
</router-link> </router-link>
<span v-else-if="report.toolassetnumber" class="asset-chip muted"
title="Measuring tool this PC controls">{{ report.toolassetnumber }}</span>
<span v-if="report.displayrole" class="asset-chip muted" <span v-if="report.displayrole" class="asset-chip muted"
title="Display role">{{ report.displayrole }}</span> title="Display role">{{ report.displayrole }}</span>
<!-- Deliberately NOT the PC's own asset number: the collector <!-- Deliberately NOT the PC's own asset number: the collector
stores a PC's hostname AS its assetnumber, so echoing it stores a PC's hostname AS its assetnumber, so echoing it
here would just repeat the Host column. Blank means this PC here would just repeat the Host column. Blank means this PC
drives nothing and is not a display. --> drives nothing and is not a display. -->
<span v-if="!report.machineassetid && !report.toolassetid && !report.displayrole" <span v-if="!report.machinenumber && !report.toolassetnumber && !report.displayrole"
class="muted">-</span> class="muted">-</span>
<div v-if="report.location" class="asset-location muted">{{ report.location }}</div> <div v-if="report.location" class="asset-location muted">{{ report.location }}</div>
</td> </td>
@@ -99,15 +109,19 @@
<td>{{ report.installed }}</td> <td>{{ report.installed }}</td>
<td class="muted">{{ report.skipped }}</td> <td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td> <td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
<!-- Last CONFIRMED, not last changed: an unchanged config writes <!-- A VERDICT, not a date. An unchanged config writes no new
no revision, so a stable machine is healthy with an old revision, so the timestamp is the last time the collector
revision. What matters is whether the backup still runs. --> CHECKED and found the backup intact - which read as neglect
<td class="muted"> when shown raw, because the healthy steady state is a
<template v-if="report.backuplastseen"> day-old confirmation. The badge names the kind and colours
{{ formatDate(report.backuplastseen) }} it good/stale; the date moves to the tooltip. Any backup
<div class="backup-kind">{{ report.backupkind }}</div> kind added later inherits this with no change here. -->
</template> <td>
<span v-else>-</span> <span v-if="report.backupkind"
class="badge"
:class="backupClass(report)"
:title="backupTitle(report)">{{ report.backupkind }}</span>
<span v-else class="muted">-</span>
</td> </td>
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td> <td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
<td class="actions"> <td class="actions">
@@ -191,6 +205,27 @@ function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || '' return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
} }
// null verdict = nothing to judge (no revision, or the check is disabled).
// Deliberately NOT green: "never seen" must not read as healthy.
function backupClass(report) {
if (report.backupok === true) return 'badge-success'
if (report.backupok === false) return 'badge-danger'
return ''
}
function backupTitle(report) {
const seen = report.backuplastseen ? formatDate(report.backuplastseen) : 'never'
if (report.backupok === true) {
return `${report.backupkind}: checked and verified ${seen}.`
+ ' Config unchanged since, which is why the date does not move.'
}
if (report.backupok === false) {
return `${report.backupkind}: last confirmed ${seen}, more than`
+ ` ${report.backupstaleafterdays} day(s) ago - the backup has stopped running.`
}
return `${report.backupkind}: last confirmed ${seen}.`
}
function staleTitle(report) { function staleTitle(report) {
const received = report.receivedat ? formatDate(report.receivedat) : 'never' const received = report.receivedat ? formatDate(report.receivedat) : 'never'
const checkin = report.lastcheckin ? formatDate(report.lastcheckin) : 'not reported' const checkin = report.lastcheckin ? formatDate(report.lastcheckin) : 'not reported'

View File

@@ -1503,26 +1503,35 @@ def supplies_forecast():
is returned separately with the reason, rather than sorted as though it is returned separately with the reason, rather than sorted as though it
were fine or dropped as though it did not exist. were fine or dropped as though it did not exist.
""" """
from ..services.supply_history import analyse, soonest from ..services.supply_history import (
ORDER_HORIZON_DAYS, analyse, band, orderlist,
)
from ..services.supply_parts import (
derivecolor, derivesupplytype, lookupsupplies,
)
try: try:
days = max(1, min(365, int(request.args.get('days', 90)))) days = max(1, min(365, int(request.args.get('days', 90))))
except (TypeError, ValueError): except (TypeError, ValueError):
days = 90 days = 90
empty = {
'cartridges': [], 'unestimated': [], 'orderlist': [], 'days': days,
'horizondays': ORDER_HORIZON_DAYS,
}
service = ZabbixService() service = ZabbixService()
if not service.isconfigured or not service.isreachable: if not service.isconfigured or not service.isreachable:
return success_response({ return success_response(dict(
'printers': [], 'unestimated': [], 'days': days, empty, available=False,
'available': False, reason='Zabbix is not configured or not reachable'))
'reason': 'Zabbix is not configured or not reachable',
})
rows = ( rows = (
db.session.query(Printer, Asset, Communication, Vendor) db.session.query(Printer, Asset, Communication, Vendor, Model)
.join(Asset, Asset.assetid == Printer.assetid) .join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid) .join(Communication, Communication.assetid == Asset.assetid)
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
.filter(Asset.isactive == True, .filter(Asset.isactive == True,
Communication.ipaddress.isnot(None), Communication.ipaddress.isnot(None),
Communication.ipaddress != '') Communication.ipaddress != '')
@@ -1530,9 +1539,9 @@ def supplies_forecast():
) )
seen = set() seen = set()
estimated, unestimated = [], [] cartridges, unestimated = [], []
for printer, asset, comm, vendor in rows: for printer, asset, comm, vendor, model in rows:
if printer.printerid in seen: if printer.printerid in seen:
continue continue
seen.add(printer.printerid) seen.add(printer.printerid)
@@ -1543,7 +1552,9 @@ def supplies_forecast():
itemids = [s['itemid'] for s in supplies if s.get('itemid')] itemids = [s['itemid'] for s in supplies if s.get('itemid')]
history = service.getlevelhistory(itemids, days=days) history = service.getlevelhistory(itemids, days=days)
analysed = [] # The cartridge is what gets ordered, so the cartridge is the row.
# Nesting supplies under a printer made the reader unpack a printer to
# find out whether anything on it needed doing.
for supply in supplies: for supply in supplies:
points = history.get(str(supply.get('itemid')), []) points = history.get(str(supply.get('itemid')), [])
# The live read is the level the report shows, so it is also the # The live read is the level the report shows, so it is also the
@@ -1551,35 +1562,50 @@ def supplies_forecast():
# row whose level and days-left came from different moments reads # row whose level and days-left came from different moments reads
# as broken. # as broken.
detail = analyse(points, currentlevel=supply.get('level')) detail = analyse(points, currentlevel=supply.get('level'))
detail['name'] = supply.get('name')
detail['color'] = supply.get('color')
analysed.append(detail)
entry = { name = supply.get('name') or 'Unknown'
color = derivecolor(name, supply.get('color'))
supplytype = derivesupplytype(name)
detail.update({
'name': name,
'color': color,
'supplytype': supplytype,
'partnumbers': lookupsupplies(
model.modelnumberid if model else None, color, supplytype),
'printerid': printer.printerid, 'printerid': printer.printerid,
'printername': asset.name or printer.hostname or '', 'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '', 'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress, 'ipaddress': comm.ipaddress,
'vendor': vendor.vendor if vendor else None, 'vendor': vendor.vendor if vendor else None,
'supplies': analysed, 'model': model.modelnumber if model else None,
'daysleft': soonest(analysed), 'band': band(detail['daysleft']),
'replacements': sum(s['replacements'] for s in analysed), })
} # The chart series is per cartridge and nothing on this report
(estimated if entry['daysleft'] is not None else unestimated).append(entry) # draws it yet; sending it multiplies the payload for nothing.
detail.pop('points', None)
(cartridges if detail['band'] else unestimated).append(detail)
# Soonest first: the point of the report is what to order next. # Soonest first: the point of the report is what to order next.
estimated.sort(key=lambda p: p['daysleft']) cartridges.sort(key=lambda c: (c['daysleft'], c['printername']))
unestimated.sort(key=lambda p: p['printername']) unestimated.sort(key=lambda c: (c['printername'], c['name']))
return success_response({ counts = {name: 0 for name in ('empty', 'soon', 'month', 'later')}
'printers': estimated, for cartridge in cartridges:
'unestimated': unestimated, counts[cartridge['band']] += 1
'days': days, toorder = orderlist(cartridges)
'available': True,
'summary': { return success_response(dict(
'estimated': len(estimated), empty,
cartridges=cartridges,
unestimated=unestimated,
orderlist=toorder,
available=True,
summary={
'bands': counts,
'estimated': len(cartridges),
'unestimated': len(unestimated), 'unestimated': len(unestimated),
'replacements': sum(p['replacements'] for p in estimated + unestimated), 'replacements': sum(c['replacements']
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30), for c in cartridges + unestimated),
'toorder': sum(item['quantity'] for item in toorder),
}, },
}) ))

View File

@@ -24,73 +24,110 @@
</div> </div>
<template v-else> <template v-else>
<div class="summary-row"> <!-- The answer first. Everything below it is the evidence. -->
<div class="summary-card"> <div class="card order-card">
<span class="summary-number">{{ summary.duewithin30 }}</span> <div class="order-head">
<span class="summary-label">due within 30 days</span> <h2 class="section-title">
Order now
<span class="muted">- empty, or out within {{ horizondays }} days</span>
</h2>
<button v-if="orderlist.length" class="btn btn-secondary" @click="copyOrder">
{{ copied ? 'Copied' : 'Copy list' }}
</button>
</div> </div>
<div class="summary-card">
<span class="summary-number">{{ summary.estimated }}</span> <p v-if="!orderlist.length" class="muted nothing-due">
<span class="summary-label">printers with an estimate</span> Nothing is due in the next {{ horizondays }} days.
</div> </p>
<div class="summary-card"> <ul v-else class="order-list">
<span class="summary-number">{{ summary.replacements }}</span> <li v-for="item in mapped" :key="orderKey(item)">
<span class="summary-label">cartridges changed in {{ days }} days</span> <span class="quantity">{{ item.quantity }}x</span>
<span class="partnumber">{{ item.partnumber }}</span>
<span class="muted order-detail">
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
</span>
<span class="muted order-printers">{{ printerNames(item) }}</span>
</li>
</ul>
<!-- A cartridge nobody has mapped a part to still has to be ordered,
so it cannot be hidden. It cannot be ordered from this page
either, so it does not get a line each - it is one job: map them. -->
<div v-if="unmapped.length" class="unmapped-block">
<button class="unmapped-head" @click="toggle('unmapped')">
<span class="band-caret">{{ isOpen('unmapped') ? '-' : '+' }}</span>
{{ unmappedCount }} more due, with no part number on file
</button>
<ul v-if="isOpen('unmapped')" class="order-list">
<li v-for="item in unmapped" :key="orderKey(item)">
<span class="quantity">{{ item.quantity }}x</span>
<span class="muted order-detail">
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
</span>
<span class="muted order-printers">{{ printerNames(item) }}</span>
</li>
</ul>
</div> </div>
</div> </div>
<div class="card"> <!-- Bands, not a sortable table: the question is "which pile is this in",
<div class="table-container"> and a pile with nothing in it is worth seeing as empty. -->
<div v-for="group in bands" :key="group.key" class="card band-card">
<button class="band-head" @click="toggle(group.key)">
<span class="band-caret">{{ isOpen(group.key) ? '-' : '+' }}</span>
<span class="band-title">{{ group.title }}</span>
<span class="band-count" :class="group.key">{{ group.items.length }}</span>
</button>
<div v-if="isOpen(group.key) && group.items.length" class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="colcolor"></th>
<th>Printer</th> <th>Printer</th>
<th>Runs out in</th>
<th>Supply</th> <th>Supply</th>
<th>Level</th> <th>Part</th>
<th>Burn rate</th> <th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th>Changed</th> <th>Changed</th>
<th>Based on</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- Sorted by days left, not by level: a cartridge at 60% falling <tr v-for="c in group.items" :key="c.printerid + c.name">
fast is ordered before one sitting at 8% that never moves. <td class="colcolor">
<span class="chip" :style="chipStyle(c.color)"
Days left is per CARTRIDGE, not per printer. It used to span :title="colorLabel(c.color)"></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 }">
<td v-if="index === 0" :rowspan="p.supplies.length">
<router-link :to="`/printers/${p.printerid}`">
{{ p.printername || p.assetnumber }}
</router-link>
<div class="muted small">{{ p.ipaddress }}</div>
</td> </td>
<td> <td>
<span v-if="s.daysleft === 0" class="days critical">empty now</span> <router-link :to="`/printers/${c.printerid}`">
<span v-else-if="s.daysleft != null" class="days" :class="urgency(s.daysleft)"> {{ c.printername || c.assetnumber }}
{{ s.daysleft }} days </router-link>
</span> <div class="muted small">{{ c.ipaddress }}</div>
</td>
<td>{{ c.name }}</td>
<td>
<span v-if="firstPart(c)" class="partnumber">{{ firstPart(c) }}</span>
<span v-else class="muted">-</span> <span v-else class="muted">-</span>
</td> </td>
<td>{{ s.name }}</td> <td class="collevel">
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td> <!-- A bar reads at a glance where a percentage has to be
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td> compared digit by digit against the row above. -->
<td>{{ s.replacements || 0 }}</td> <span class="bar" :title="levelText(c)">
<td class="muted small"> <span class="bar-fill" :class="c.band"
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }} :style="{ width: barWidth(c) }"></span>
</span>
<span class="level-text">{{ levelText(c) }}</span>
</td> </td>
</tr> <td>
</template> <span class="days" :class="c.band">{{ daysText(c) }}</span>
<tr v-if="!printers.length"> </td>
<td colspan="7" class="muted" style="text-align:center;"> <td class="muted small">
Nothing can be estimated yet from {{ days }} days of history. {{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }}
</td>
<td class="muted small">
{{ c.replacements || 0 }}
<span v-if="c.basisdays">/ {{ c.basisdays }}d</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -98,38 +135,51 @@
</div> </div>
</div> </div>
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no <!-- Kept apart rather than sorted in as 0 or as 999: a cartridge with no
estimate is neither urgent nor safe, and the reason is the useful part. --> estimate is neither urgent nor safe, and the reason is the point. -->
<div v-if="unestimated.length" class="card"> <div v-if="unestimated.length" class="card band-card">
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3> <button class="band-head" @click="toggle('none')">
<div class="table-container"> <span class="band-caret">{{ isOpen('none') ? '-' : '+' }}</span>
<span class="band-title">No estimate yet</span>
<span class="band-count">{{ unestimated.length }}</span>
</button>
<div v-if="isOpen('none')" class="table-container">
<table> <table>
<thead> <thead>
<tr><th>Printer</th><th>Supply</th><th>Level</th><th>Why</th></tr> <tr>
<th class="colcolor"></th>
<th>Printer</th><th>Supply</th><th class="collevel">Level</th><th>Why</th>
</tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="p in unestimated" :key="p.printerid"> <tr v-for="c in unestimated" :key="c.printerid + c.name">
<tr v-for="s in p.supplies" :key="p.printerid + s.name"> <td class="colcolor">
<span class="chip" :style="chipStyle(c.color)"></span>
</td>
<td> <td>
<router-link :to="`/printers/${p.printerid}`"> <router-link :to="`/printers/${c.printerid}`">
{{ p.printername || p.assetnumber }} {{ c.printername || c.assetnumber }}
</router-link> </router-link>
</td> </td>
<td>{{ s.name }}</td> <td>{{ c.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td> <td class="collevel">{{ levelText(c) }}</td>
<td class="muted">{{ s.reason || '-' }}</td> <td class="muted">{{ c.reason || '-' }}</td>
</tr> </tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<p class="muted footnote">
{{ summary.replacements }} cartridges changed across the fleet in the
last {{ days }} days.
</p>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
@@ -137,14 +187,117 @@ const loading = ref(true)
const available = ref(false) const available = ref(false)
const reason = ref('') const reason = ref('')
const days = ref(90) const days = ref(90)
const printers = ref([]) const horizondays = ref(14)
const cartridges = ref([])
const unestimated = ref([]) const unestimated = ref([])
const summary = ref({ duewithin30: 0, estimated: 0, replacements: 0 }) const orderlist = ref([])
const summary = ref({ bands: {}, replacements: 0, toorder: 0 })
const copied = ref(false)
function urgency(daysleft) { // Only what is already empty starts open. The order list above covers the same
if (daysleft <= 7) return 'critical' // ground in a tenth of the height, so the bands are evidence, asked for.
if (daysleft <= 30) return 'low' const openBands = ref(new Set(['empty']))
return 'ok'
const mapped = computed(() => orderlist.value.filter(item => item.partnumber))
const unmapped = computed(() => orderlist.value.filter(item => !item.partnumber))
const unmappedCount = computed(() =>
unmapped.value.reduce((total, item) => total + item.quantity, 0))
const BAND_TITLES = {
empty: 'Empty - order now',
soon: 'Within 2 weeks',
month: 'Within 30 days',
later: 'Later',
}
const bands = computed(() =>
Object.keys(BAND_TITLES).map(key => ({
key,
title: BAND_TITLES[key],
items: cartridges.value.filter(c => c.band === key),
})))
function isOpen(key) {
return openBands.value.has(key)
}
function toggle(key) {
const next = new Set(openBands.value)
next.has(key) ? next.delete(key) : next.add(key)
openBands.value = next
}
// Toner colours are the one place a literal colour is the data rather than a
// theme choice - a cyan chip has to be cyan in both light and dark mode.
const CHIP_COLORS = {
black: '#2b2b2b', cyan: '#00b7eb', magenta: '#d6006e', yellow: '#f2c200',
}
function chipStyle(color) {
const known = CHIP_COLORS[(color || '').toLowerCase()]
return known
? { background: known }
: { background: 'var(--bg)', border: '1px solid var(--border)' }
}
function colorLabel(color) {
if (!color || color === 'none') return 'Supply'
return color.charAt(0).toUpperCase() + color.slice(1)
}
function firstPart(cartridge) {
const parts = cartridge.partnumbers || []
return parts.length ? parts[0].partnumber : null
}
function levelText(cartridge) {
return cartridge.currentlevel != null
? Math.round(cartridge.currentlevel) + '%'
: '-'
}
function barWidth(cartridge) {
const level = cartridge.currentlevel
if (level == null) return '0%'
return Math.max(0, Math.min(100, level)) + '%'
}
function daysText(cartridge) {
if (cartridge.daysleft == null) return '-'
if (cartridge.daysleft === 0) return 'empty'
if (cartridge.daysleft === 1) return '1 day'
return cartridge.daysleft + ' days'
}
function orderKey(item) {
return [item.partnumber || 'unmapped', item.color, item.model].join('|')
}
// Enough to recognise which printers are meant, without a line of hostnames
// wrapping over the quantity that is the point of the row.
const NAMES_SHOWN = 3
function printerNames(item) {
const names = (item.printers || []).map(p => p.printername).filter(Boolean)
if (names.length <= NAMES_SHOWN) return names.join(', ')
return `${names.slice(0, NAMES_SHOWN).join(', ')} +${names.length - NAMES_SHOWN} more`
}
// Purchasing wants it as text in a mail, not as a screenshot of a table.
async function copyOrder() {
const lines = orderlist.value.map(item => {
const part = item.partnumber || 'NO PART NUMBER ON FILE'
const where = printerNames(item)
return `${item.quantity}x ${part} (${colorLabel(item.color)}` +
`${item.model ? ', ' + item.model : ''}) - ${where}`
})
try {
await navigator.clipboard.writeText(lines.join('\n'))
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
copied.value = false
}
} }
async function load() { async function load() {
@@ -154,8 +307,10 @@ async function load() {
const data = response.data.data const data = response.data.data
available.value = data.available available.value = data.available
reason.value = data.reason || '' reason.value = data.reason || ''
printers.value = data.printers || [] cartridges.value = data.cartridges || []
unestimated.value = data.unestimated || [] unestimated.value = data.unestimated || []
orderlist.value = data.orderlist || []
horizondays.value = data.horizondays || 14
summary.value = data.summary || summary.value summary.value = data.summary || summary.value
} catch (err) { } catch (err) {
available.value = false available.value = false
@@ -171,21 +326,104 @@ onMounted(load)
<style scoped> <style scoped>
.actions { display: flex; gap: 0.5rem; align-items: center; } .actions { display: flex; gap: 0.5rem; align-items: center; }
.actions .form-control { width: auto; } .actions .form-control { width: auto; }
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
.summary-card { .order-card { padding: 0 0 0.5rem; }
background: var(--bg-card-solid); .order-head {
border: 1px solid var(--border); display: flex;
border-radius: 0.5rem; align-items: center;
padding: 1rem 1.5rem; justify-content: space-between;
min-width: 12rem; gap: 1rem;
padding: 0 1rem;
} }
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; } .order-head .section-title { padding: 0.75rem 0; }
.summary-label { color: var(--text-light); font-size: 0.85rem; } .nothing-due { padding: 0 1rem 0.75rem; }
.order-list { list-style: none; margin: 0; padding: 0 1rem; }
.order-list li {
display: flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.4rem 0;
border-top: 1px solid var(--border);
}
.quantity { font-weight: 700; min-width: 2.5rem; }
.unmapped-block { border-top: 1px solid var(--border); }
.unmapped-head {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem 1rem;
background: none;
border: none;
color: var(--warning);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.unmapped-block .order-list li { border-top: 1px dashed var(--border); }
.partnumber { font-family: monospace; font-weight: 700; }
.partnumber.unmapped { font-weight: 400; color: var(--warning); }
.order-detail { font-size: 0.9rem; }
.order-printers { font-size: 0.8rem; margin-left: auto; text-align: right; }
.band-card { padding: 0; margin-bottom: 1rem; }
.band-head {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
color: var(--text);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.band-caret { width: 1rem; color: var(--text-light); }
.band-title { flex: 1; }
.band-count {
min-width: 1.8rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: var(--bg);
border: 1px solid var(--border);
text-align: center;
font-size: 0.85rem;
}
.band-count.empty { background: var(--danger); border-color: var(--danger); color: #fff; }
.band-count.soon { background: var(--warning); border-color: var(--warning); color: #fff; }
.colcolor { width: 1.5rem; }
.chip {
display: inline-block;
width: 0.9rem;
height: 0.9rem;
border-radius: 50%;
vertical-align: middle;
}
.collevel { width: 9rem; white-space: nowrap; }
.bar {
display: inline-block;
width: 5rem;
height: 0.6rem;
border-radius: 0.3rem;
background: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
vertical-align: middle;
}
.bar-fill { display: block; height: 100%; background: var(--success); }
.bar-fill.empty { background: var(--danger); }
.bar-fill.soon { background: var(--warning); }
.level-text { margin-left: 0.4rem; font-size: 0.85rem; }
.days { font-weight: 700; } .days { font-weight: 700; }
.days.critical { color: var(--danger); } .days.empty { color: var(--danger); }
.days.low { color: var(--warning); } .days.soon { color: var(--warning); }
.small { font-size: 0.8rem; } .small { font-size: 0.8rem; }
.row-group-start td { border-top: 2px solid var(--border); }
.empty-state { padding: 2rem; text-align: center; } .empty-state { padding: 2rem; text-align: center; }
.section-title { padding: 0.75rem 1rem 0; } .footnote { margin-top: 1rem; font-size: 0.85rem; }
</style> </style>

View File

@@ -185,6 +185,76 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
return result return result
# Urgency bands. The report groups by these and the order list is drawn from
# the first two, so they are defined once here rather than in the view - a
# heading that disagrees with what got added to the list is worse than either.
SOON_DAYS = 14
MONTH_DAYS = 30
# What goes on the order list. Two weeks is the horizon that survives a
# delivery: ordering only what is already empty means running empty.
ORDER_HORIZON_DAYS = SOON_DAYS
BANDS = ('empty', 'soon', 'month', 'later')
def band(daysleft):
"""Which urgency band a cartridge belongs in, or None with no estimate."""
if daysleft is None:
return None
if daysleft <= 0:
return 'empty'
if daysleft <= SOON_DAYS:
return 'soon'
if daysleft <= MONTH_DAYS:
return 'month'
return 'later'
def orderlist(cartridges, horizon=ORDER_HORIZON_DAYS):
"""What to buy, grouped by part number: [{partnumber, quantity, ...}].
The report's whole purpose reduced to a list someone can hand to
purchasing. Two cartridges of the same part in different printers is a
quantity of two, which is the number an order needs and the one a
per-printer table makes you count by hand.
A cartridge with no part mapped is still listed, under its printer's model
and colour. Dropping it would quietly shorten the order.
"""
groups = {}
for cartridge in cartridges:
if cartridge.get('daysleft') is None or cartridge['daysleft'] > horizon:
continue
parts = cartridge.get('partnumbers') or []
# Several capacity tiers can match; the first is the standard one and
# is what the low-supplies report shows first too.
partnumber = parts[0]['partnumber'] if parts else None
key = (partnumber, cartridge.get('color'), cartridge.get('model'))
group = groups.setdefault(key, {
'partnumber': partnumber,
'color': cartridge.get('color'),
'supplytype': cartridge.get('supplytype'),
'model': cartridge.get('model'),
'marketingname': parts[0].get('marketingname') if parts else None,
'alternates': [p['partnumber'] for p in parts[1:]],
'quantity': 0,
'printers': [],
})
group['quantity'] += 1
group['printers'].append({
'printerid': cartridge.get('printerid'),
'printername': cartridge.get('printername'),
'daysleft': cartridge.get('daysleft'),
})
# Unmapped parts last: they need a decision before they can be ordered.
return sorted(groups.values(),
key=lambda g: (g['partnumber'] is None,
-g['quantity'],
g['partnumber'] or ''))
def soonest(supplies): def soonest(supplies):
"""Days-left of the supply that runs out first, or None if none estimate. """Days-left of the supply that runs out first, or None if none estimate.

View File

@@ -336,3 +336,128 @@ def test_a_future_checkin_cannot_mask_server_side_silence(client, db, app,
row = client.get('/api/geenforce/reports', row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0] headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is True assert row['isstale'] is True
def test_asset_link_uses_the_plugin_id_not_the_assetid(client, db, app, auth_headers):
"""/machines/:id keys on machineid, NOT the core assetid.
Returning the assetid made the fleet table link to whichever machine
happened to carry that number - a wrong page that looks right rather than a
404. The frontend links on machinepluginid for exactly this reason.
"""
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
from plugins.computers.models import Computer
from plugins.machines.models import Machine
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
computertype = AssetType.query.filter_by(assettype='computer').first() \
or AssetType(assettype='computer')
machinetype = AssetType.query.filter_by(assettype='machine').first() \
or AssetType(assettype='machine')
db.session.add_all([computertype, machinetype])
db.session.flush()
pcasset = Asset(assetnumber='WJBAY01', assettypeid=computertype.assettypeid)
machineasset = Asset(assetnumber='6905', assettypeid=machinetype.assettypeid)
db.session.add_all([pcasset, machineasset])
db.session.flush()
db.session.add(Computer(assetid=pcasset.assetid, hostname='WJBAY01'))
machine = Machine(assetid=machineasset.assetid)
db.session.add(machine)
db.session.flush()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
if not controls:
controls = RelationshipType(relationshiptype='controls')
db.session.add(controls)
db.session.flush()
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid, targetassetid=machineasset.assetid,
relationshiptypeid=controls.relationshiptypeid, label='collector:machine'))
db.session.commit()
client.post('/api/geenforce/report', json={
'hostname': 'WJBAY01', 'scopename': 'gea-shopfloor-cmm', 'counts': {},
}, headers={'X-API-Key': secret})
row = [r for r in client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data']
if r['hostname'] == 'WJBAY01'][0]
assert row['machinenumber'] == '6905'
assert row['machineassetid'] == machineasset.assetid
assert row['machinepluginid'] == machine.machineid
# -- backup verdict ----------------------------------------------------------
#
# The column used to show a raw date, which read as neglect: dedup means an
# unchanged config writes no new revision, so a day-old confirmation IS the
# healthy steady state at the default 24h collection interval.
def _backup_revision(db, hostname, kind, ageda):
from datetime import timedelta
from plugins.backups.models import BackupRevision
from plugins.geenforce.service import _utcnow
from shopdb.core.models import Asset, AssetType
from plugins.computers.models import Computer
computertype = AssetType.query.filter_by(assettype='computer').first() \
or AssetType(assettype='computer')
db.session.add(computertype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=computertype.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(BackupRevision(assetid=asset.assetid, backupkind=kind,
sourcehostname=hostname,
contenthash='0' * 64,
lastseenat=_utcnow() - timedelta(days=ageda)))
db.session.commit()
def _row_for(client, auth_headers, hostname):
return [r for r in client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data']
if r['hostname'] == hostname][0]
def test_a_recent_check_is_good_even_though_nothing_changed(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK01', 'ntlars', ageda=1)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK01', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK01')
assert row['backupkind'] == 'ntlars'
assert row['backupok'] is True, 'a day old is the healthy steady state'
assert row['backupstaleafterdays'] == 3
def test_a_check_older_than_the_threshold_is_stale(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK02', 'ntlars', ageda=9)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK02', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
assert _row_for(client, auth_headers, 'WJBAK02')['backupok'] is False
def test_no_backup_at_all_is_unknown_not_good(client, db, app, auth_headers):
"""None, never True: 'never seen' must not render as a green badge."""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK03', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK03')
assert row['backupkind'] is None
assert row['backupok'] is None

View File

@@ -9,7 +9,8 @@ purchasing decision.
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from plugins.printers.services.supply_history import ( from plugins.printers.services.supply_history import (
analyse, burn_rate, current_run, find_replacements, normalise, soonest, analyse, band, burn_rate, current_run, find_replacements, normalise,
orderlist, soonest,
) )
START = datetime(2026, 6, 1, tzinfo=timezone.utc) START = datetime(2026, 6, 1, tzinfo=timezone.utc)
@@ -170,6 +171,91 @@ def test_burn_rate_needs_elapsed_time():
assert burn_rate(points) is None assert burn_rate(points) is None
def cartridge(daysleft, partnumber='CF258X', color='black', printername='PRN',
model='HP 428', printerid=1):
parts = [{'partnumber': partnumber, 'marketingname': None,
'capacitytier': 'standard'}] if partnumber else []
return {'daysleft': daysleft, 'partnumbers': parts, 'color': color,
'supplytype': 'toner', 'model': model, 'printerid': printerid,
'printername': printername}
def test_bands_split_at_two_weeks_and_a_month():
assert band(0) == 'empty'
assert band(1) == 'soon'
assert band(14) == 'soon'
assert band(15) == 'month'
assert band(30) == 'month'
assert band(31) == 'later'
def test_no_estimate_belongs_to_no_band():
assert band(None) is None
def test_the_same_part_in_two_printers_is_a_quantity_of_two():
"""The number purchasing needs, and the one a per-printer table makes you
count by hand."""
result = orderlist([cartridge(0, printername='A', printerid=1),
cartridge(3, printername='B', printerid=2)])
assert len(result) == 1
assert result[0]['quantity'] == 2
assert [p['printername'] for p in result[0]['printers']] == ['A', 'B']
def test_the_order_list_stops_at_the_horizon():
"""Ordering only what is already empty means running empty; ordering three
months out is a stock cupboard."""
result = orderlist([cartridge(0), cartridge(90, partnumber='W2021A')])
assert [item['partnumber'] for item in result] == ['CF258X']
def test_a_cartridge_with_no_part_mapped_is_still_on_the_list():
"""Dropping it would quietly shorten the order."""
result = orderlist([cartridge(2, partnumber=None)])
assert len(result) == 1
assert result[0]['partnumber'] is None
assert result[0]['quantity'] == 1
def test_unmapped_parts_sort_last():
"""They need a decision before anything can be ordered, so they do not sit
at the top of a list meant to be read straight down."""
result = orderlist([cartridge(1, partnumber=None), cartridge(1)])
assert result[0]['partnumber'] == 'CF258X'
assert result[-1]['partnumber'] is None
def test_alternate_capacity_tiers_are_offered_not_counted_separately():
item = {**cartridge(1), 'partnumbers': [
{'partnumber': 'CF258A', 'marketingname': None, 'capacitytier': 'standard'},
{'partnumber': 'CF258X', 'marketingname': None, 'capacitytier': 'high'},
]}
result = orderlist([item])
assert len(result) == 1
assert result[0]['partnumber'] == 'CF258A'
assert result[0]['alternates'] == ['CF258X']
def test_the_same_part_for_a_different_model_is_ordered_separately():
"""Two models sharing a part number is a mapping error worth seeing, not a
quantity to merge."""
result = orderlist([cartridge(1, model='HP 428'),
cartridge(1, model='HP M454')])
assert len(result) == 2
def test_no_estimate_never_reaches_the_order_list():
assert orderlist([cartridge(None)]) == []
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db): def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
"""A dashboard must be told the data is missing, not shown an empty list """A dashboard must be told the data is missing, not shown an empty list
that reads as 'nothing runs out soon'.""" that reads as 'nothing runs out soon'."""
@@ -179,4 +265,5 @@ def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
body = response.get_json()['data'] body = response.get_json()['data']
assert body['available'] is False assert body['available'] is False
assert 'not configured' in body['reason'] assert 'not configured' in body['reason']
assert body['printers'] == [] assert body['cartridges'] == []
assert body['orderlist'] == []

View File

@@ -0,0 +1,165 @@
"""Tests for the assembled toner forecast response.
The arithmetic is covered in test_supply_history; this is about the shape the
report is read through - one row per cartridge, sorted into urgency bands, and
an order list a person can hand to purchasing. Zabbix is stubbed, because what
is being checked is the assembly, not the client.
"""
import pytest
from plugins.printers.services.zabbix_service import ZabbixService
from shopdb.core.models import Asset, AssetType, Communication, CommunicationType
from shopdb.core.models import Model, Vendor
from plugins.printers.models import ModelSupply, Printer
LEVELS = {
# itemid: (name, live level, history of daily readings)
'10': ('Black Cartridge', 1, [6, 5, 4, 3]),
'11': ('Cyan Cartridge', 40, [100, 90, 80, 70, 60, 50, 40]),
'12': ('Yellow Cartridge', 92, [95, 94, 93, 92]),
}
START = 1780000000
class StubZabbix(ZabbixService):
"""Answers from LEVELS, so no network and no Zabbix instance."""
@property
def isconfigured(self):
return True
@property
def isreachable(self):
return True
def getsuppliesbyip_cached(self, ip):
return [{'name': name, 'level': level, 'color': None, 'itemid': itemid}
for itemid, (name, level, _) in LEVELS.items()]
def getlevelhistory(self, itemids, days=90):
return {
itemid: [(str(START + i * 86400), str(value))
for i, value in enumerate(history)]
for itemid, (_, _, history) in LEVELS.items()
if itemid in [str(i) for i in itemids]
}
@pytest.fixture
def printer_with_parts(app, db, monkeypatch):
"""One HP printer, three cartridges, two of them with a part on file."""
monkeypatch.setattr('plugins.printers.api.asset_routes.ZabbixService',
StubZabbix)
with app.app_context():
vendor = Vendor(vendor='HP')
db.session.add(vendor)
db.session.flush()
model = Model(modelnumber='HP M454', vendorid=vendor.vendorid)
db.session.add(model)
db.session.flush()
assettype = AssetType.query.filter_by(assettype='printer').first()
if not assettype:
assettype = AssetType(assettype='printer', pluginname='printers',
tablename='printers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber='PRN-1', name='WJ-PRN-0122',
assettypeid=assettype.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Printer(assetid=asset.assetid, vendorid=vendor.vendorid,
modelnumberid=model.modelnumberid))
comtype = CommunicationType.query.filter_by(comtype='IP').first()
if not comtype:
comtype = CommunicationType(comtype='IP')
db.session.add(comtype)
db.session.flush()
db.session.add(Communication(assetid=asset.assetid,
comtypeid=comtype.comtypeid,
ipaddress='10.20.30.40', isprimary=True))
# Black and cyan are mapped; yellow deliberately is not.
db.session.add_all([
ModelSupply(modelnumberid=model.modelnumberid, supplytype='toner',
color='black', capacitytier='standard',
partnumber='W2020A'),
ModelSupply(modelnumberid=model.modelnumberid, supplytype='toner',
color='cyan', capacitytier='standard',
partnumber='W2021A'),
])
db.session.commit()
yield
def forecast(client):
response = client.get('/api/printers/supplies/forecast?days=90')
assert response.status_code == 200
return response.get_json()['data']
def test_a_row_is_a_cartridge_not_a_printer(client, printer_with_parts):
"""The thing being ordered is the cartridge, so it is the unit of the
report. Nested under a printer, a reader had to open a printer to find out
whether anything on it needed doing."""
data = forecast(client)
names = sorted(c['name'] for c in data['cartridges'] + data['unestimated'])
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Yellow Cartridge']
def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
"""The defect this replaces: one printer-level figure printed beside every
cartridge, so a healthy one wore its neighbour's deadline."""
data = forecast(client)
bylevel = {c['name']: c for c in data['cartridges']}
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 1%
assert bylevel['Cyan Cartridge']['daysleft'] == 4 # 40% at 10%/day
assert len({c['daysleft'] for c in data['cartridges']}) > 1
def test_cartridges_are_banded_by_urgency(client, printer_with_parts):
data = forecast(client)
byname = {c['name']: c['band'] for c in data['cartridges']}
assert byname['Black Cartridge'] == 'empty'
assert byname['Cyan Cartridge'] == 'soon'
assert data['summary']['bands']['empty'] == 1
def test_the_order_list_names_the_part_and_the_quantity(client, printer_with_parts):
data = forecast(client)
byparts = {item['partnumber']: item for item in data['orderlist']}
assert byparts['W2020A']['quantity'] == 1
assert byparts['W2020A']['printers'][0]['printername'] == 'WJ-PRN-0122'
assert 'W2021A' in byparts
def test_a_healthy_cartridge_stays_off_the_order_list(client, printer_with_parts):
"""Yellow at 92% is months away and belongs in the tail, not in an order."""
data = forecast(client)
assert all(item['color'] != 'yellow' for item in data['orderlist'])
def test_cartridges_are_sorted_soonest_first(client, printer_with_parts):
data = forecast(client)
daysleft = [c['daysleft'] for c in data['cartridges']]
assert daysleft == sorted(daysleft)
def test_the_horizon_is_published_so_the_page_can_name_it(client, printer_with_parts):
"""The heading says "within N days"; N has to come from whatever decided
the list, or the two drift apart."""
data = forecast(client)
assert data['horizondays'] == 14