Shadow task fixes, asset link ids, backup verdict
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
- The toner forecast was reading the wrong end of the window. Zabbix applies a
|
||||
|
||||
@@ -955,9 +955,12 @@ def _asset_facts(hostnames):
|
||||
'mapx': asset.mapx,
|
||||
'mapy': asset.mapy,
|
||||
'machinenumber': None, 'machineassetid': None,
|
||||
'machinepluginid': None,
|
||||
'toolassetnumber': None, 'toolassetid': None,
|
||||
'toolpluginid': None,
|
||||
'displayrole': None,
|
||||
'backupkind': None, 'backuplastseen': None,
|
||||
'backuplastseenat': None,
|
||||
}
|
||||
|
||||
_attach_controlled_assets(facts, assetidtohost)
|
||||
@@ -993,11 +996,24 @@ def _attach_controlled_assets(facts, assetidtohost):
|
||||
AssetRelationship.sourceassetid.in_(assetidtohost))
|
||||
.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:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
toolassetids = {row.assetid for row in
|
||||
MeasuringTool.query.with_entities(MeasuringTool.assetid)}
|
||||
toolids = {row.assetid: row.measuringtoolid for row in
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -1005,12 +1021,17 @@ def _attach_controlled_assets(facts, assetidtohost):
|
||||
entry = facts.get(assetidtohost.get(sourceassetid))
|
||||
if not entry:
|
||||
continue
|
||||
if asset.assetid in toolassetids:
|
||||
if asset.assetid in toolids:
|
||||
entry['toolassetnumber'] = asset.assetnumber
|
||||
entry['toolassetid'] = asset.assetid
|
||||
entry['toolpluginid'] = toolids.get(asset.assetid)
|
||||
else:
|
||||
entry['machinenumber'] = asset.assetnumber
|
||||
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):
|
||||
@@ -1059,6 +1080,51 @@ def _attach_backup_state(facts, hostnames):
|
||||
entry['backuplastseen'] = (
|
||||
revision.lastseenat.isoformat() + 'Z'
|
||||
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():
|
||||
@@ -1108,6 +1174,7 @@ def list_reports():
|
||||
if report.hostname})
|
||||
|
||||
stalecutoff, stalafter = _report_stale_cutoff()
|
||||
backupcutoff, backupafterdays = _backup_stale_cutoff()
|
||||
latest_cache = {}
|
||||
data = []
|
||||
for report in reports:
|
||||
@@ -1142,13 +1209,23 @@ def list_reports():
|
||||
'mapy': known.get('mapy'),
|
||||
'machinenumber': known.get('machinenumber'),
|
||||
'machineassetid': known.get('machineassetid'),
|
||||
'machinepluginid': known.get('machinepluginid'),
|
||||
'toolassetnumber': known.get('toolassetnumber'),
|
||||
'toolassetid': known.get('toolassetid'),
|
||||
'toolpluginid': known.get('toolpluginid'),
|
||||
# Reported by the device wins; the DashboardDefault mapping is a
|
||||
# fallback for hosts on an older client that does not send it.
|
||||
'displayrole': report.subtype or known.get('displayrole'),
|
||||
'backupkind': known.get('backupkind'),
|
||||
'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,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
|
||||
@@ -20,12 +20,28 @@ $ErrorActionPreference = 'Continue'
|
||||
$TaskName = 'ShopDB GE-Enforce (shadow)'
|
||||
$InstallDir = 'C:\Program Files\GE\Shopfloor'
|
||||
$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 {
|
||||
# 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)
|
||||
$line = "[{0}] [shadow-setup] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
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 {
|
||||
@@ -60,6 +76,37 @@ try {
|
||||
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Scope "{1}" -EnginePath "{2}" -ShadowMode -ShareManifestPath "{3}"' `
|
||||
-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
|
||||
# RepetitionInterval ALONE - passing RepetitionDuration serializes to a
|
||||
# Duration the Task Scheduler schema rejects.
|
||||
@@ -69,6 +116,9 @@ try {
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||
-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"
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -61,23 +61,33 @@
|
||||
<!-- What this PC IS or DRIVES, whichever applies. A part-marker
|
||||
or bay PC shows its machine; a measuring-tool PC its tool;
|
||||
a display its role. -->
|
||||
<router-link v-if="report.machineassetid" class="asset-chip"
|
||||
:to="`/machines/${report.machineassetid}`"
|
||||
<!-- /machines/:id and /measuringtools/:id key on the PLUGIN
|
||||
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">
|
||||
{{ report.machinenumber }}
|
||||
</router-link>
|
||||
<router-link v-if="report.toolassetid" class="asset-chip"
|
||||
:to="`/measuringtools/${report.toolassetid}`"
|
||||
<span v-else-if="report.machinenumber" class="asset-chip muted"
|
||||
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">
|
||||
{{ report.toolassetnumber }}
|
||||
</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"
|
||||
title="Display role">{{ report.displayrole }}</span>
|
||||
<!-- Deliberately NOT the PC's own asset number: the collector
|
||||
stores a PC's hostname AS its assetnumber, so echoing it
|
||||
here would just repeat the Host column. Blank means this PC
|
||||
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>
|
||||
<div v-if="report.location" class="asset-location muted">{{ report.location }}</div>
|
||||
</td>
|
||||
@@ -99,15 +109,19 @@
|
||||
<td>{{ report.installed }}</td>
|
||||
<td class="muted">{{ report.skipped }}</td>
|
||||
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
|
||||
<!-- Last CONFIRMED, not last changed: an unchanged config writes
|
||||
no revision, so a stable machine is healthy with an old
|
||||
revision. What matters is whether the backup still runs. -->
|
||||
<td class="muted">
|
||||
<template v-if="report.backuplastseen">
|
||||
{{ formatDate(report.backuplastseen) }}
|
||||
<div class="backup-kind">{{ report.backupkind }}</div>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
<!-- A VERDICT, not a date. An unchanged config writes no new
|
||||
revision, so the timestamp is the last time the collector
|
||||
CHECKED and found the backup intact - which read as neglect
|
||||
when shown raw, because the healthy steady state is a
|
||||
day-old confirmation. The badge names the kind and colours
|
||||
it good/stale; the date moves to the tooltip. Any backup
|
||||
kind added later inherits this with no change here. -->
|
||||
<td>
|
||||
<span v-if="report.backupkind"
|
||||
class="badge"
|
||||
:class="backupClass(report)"
|
||||
:title="backupTitle(report)">{{ report.backupkind }}</span>
|
||||
<span v-else class="muted">-</span>
|
||||
</td>
|
||||
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
|
||||
<td class="actions">
|
||||
@@ -191,6 +205,27 @@ function statusClass(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) {
|
||||
const received = report.receivedat ? formatDate(report.receivedat) : 'never'
|
||||
const checkin = report.lastcheckin ? formatDate(report.lastcheckin) : 'not reported'
|
||||
|
||||
@@ -1503,26 +1503,35 @@ def supplies_forecast():
|
||||
is returned separately with the reason, rather than sorted as though it
|
||||
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:
|
||||
days = max(1, min(365, int(request.args.get('days', 90))))
|
||||
except (TypeError, ValueError):
|
||||
days = 90
|
||||
|
||||
empty = {
|
||||
'cartridges': [], 'unestimated': [], 'orderlist': [], 'days': days,
|
||||
'horizondays': ORDER_HORIZON_DAYS,
|
||||
}
|
||||
|
||||
service = ZabbixService()
|
||||
if not service.isconfigured or not service.isreachable:
|
||||
return success_response({
|
||||
'printers': [], 'unestimated': [], 'days': days,
|
||||
'available': False,
|
||||
'reason': 'Zabbix is not configured or not reachable',
|
||||
})
|
||||
return success_response(dict(
|
||||
empty, available=False,
|
||||
reason='Zabbix is not configured or not reachable'))
|
||||
|
||||
rows = (
|
||||
db.session.query(Printer, Asset, Communication, Vendor)
|
||||
db.session.query(Printer, Asset, Communication, Vendor, Model)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.join(Communication, Communication.assetid == Asset.assetid)
|
||||
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
|
||||
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
|
||||
.filter(Asset.isactive == True,
|
||||
Communication.ipaddress.isnot(None),
|
||||
Communication.ipaddress != '')
|
||||
@@ -1530,9 +1539,9 @@ def supplies_forecast():
|
||||
)
|
||||
|
||||
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:
|
||||
continue
|
||||
seen.add(printer.printerid)
|
||||
@@ -1543,7 +1552,9 @@ def supplies_forecast():
|
||||
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
|
||||
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:
|
||||
points = history.get(str(supply.get('itemid')), [])
|
||||
# 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
|
||||
# as broken.
|
||||
detail = analyse(points, currentlevel=supply.get('level'))
|
||||
detail['name'] = supply.get('name')
|
||||
detail['color'] = supply.get('color')
|
||||
analysed.append(detail)
|
||||
|
||||
entry = {
|
||||
'printerid': printer.printerid,
|
||||
'printername': asset.name or printer.hostname or '',
|
||||
'assetnumber': asset.assetnumber or '',
|
||||
'ipaddress': comm.ipaddress,
|
||||
'vendor': vendor.vendor if vendor else None,
|
||||
'supplies': analysed,
|
||||
'daysleft': soonest(analysed),
|
||||
'replacements': sum(s['replacements'] for s in analysed),
|
||||
}
|
||||
(estimated if entry['daysleft'] is not None else unestimated).append(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,
|
||||
'printername': asset.name or printer.hostname or '',
|
||||
'assetnumber': asset.assetnumber or '',
|
||||
'ipaddress': comm.ipaddress,
|
||||
'vendor': vendor.vendor if vendor else None,
|
||||
'model': model.modelnumber if model else None,
|
||||
'band': band(detail['daysleft']),
|
||||
})
|
||||
# The chart series is per cartridge and nothing on this report
|
||||
# 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.
|
||||
estimated.sort(key=lambda p: p['daysleft'])
|
||||
unestimated.sort(key=lambda p: p['printername'])
|
||||
cartridges.sort(key=lambda c: (c['daysleft'], c['printername']))
|
||||
unestimated.sort(key=lambda c: (c['printername'], c['name']))
|
||||
|
||||
return success_response({
|
||||
'printers': estimated,
|
||||
'unestimated': unestimated,
|
||||
'days': days,
|
||||
'available': True,
|
||||
'summary': {
|
||||
'estimated': len(estimated),
|
||||
counts = {name: 0 for name in ('empty', 'soon', 'month', 'later')}
|
||||
for cartridge in cartridges:
|
||||
counts[cartridge['band']] += 1
|
||||
toorder = orderlist(cartridges)
|
||||
|
||||
return success_response(dict(
|
||||
empty,
|
||||
cartridges=cartridges,
|
||||
unestimated=unestimated,
|
||||
orderlist=toorder,
|
||||
available=True,
|
||||
summary={
|
||||
'bands': counts,
|
||||
'estimated': len(cartridges),
|
||||
'unestimated': len(unestimated),
|
||||
'replacements': sum(p['replacements'] for p in estimated + unestimated),
|
||||
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30),
|
||||
'replacements': sum(c['replacements']
|
||||
for c in cartridges + unestimated),
|
||||
'toorder': sum(item['quantity'] for item in toorder),
|
||||
},
|
||||
})
|
||||
))
|
||||
|
||||
@@ -24,73 +24,110 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span class="summary-number">{{ summary.duewithin30 }}</span>
|
||||
<span class="summary-label">due within 30 days</span>
|
||||
<!-- The answer first. Everything below it is the evidence. -->
|
||||
<div class="card order-card">
|
||||
<div class="order-head">
|
||||
<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 class="summary-card">
|
||||
<span class="summary-number">{{ summary.estimated }}</span>
|
||||
<span class="summary-label">printers with an estimate</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-number">{{ summary.replacements }}</span>
|
||||
<span class="summary-label">cartridges changed in {{ days }} days</span>
|
||||
|
||||
<p v-if="!orderlist.length" class="muted nothing-due">
|
||||
Nothing is due in the next {{ horizondays }} days.
|
||||
</p>
|
||||
<ul v-else class="order-list">
|
||||
<li v-for="item in mapped" :key="orderKey(item)">
|
||||
<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 class="card">
|
||||
<div class="table-container">
|
||||
<!-- Bands, not a sortable table: the question is "which pile is this in",
|
||||
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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="colcolor"></th>
|
||||
<th>Printer</th>
|
||||
<th>Runs out in</th>
|
||||
<th>Supply</th>
|
||||
<th>Level</th>
|
||||
<th>Burn rate</th>
|
||||
<th>Part</th>
|
||||
<th class="collevel">Level</th>
|
||||
<th>Runs out</th>
|
||||
<th>Rate</th>
|
||||
<th>Changed</th>
|
||||
<th>Based on</th>
|
||||
</tr>
|
||||
</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.
|
||||
|
||||
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 }">
|
||||
<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>
|
||||
<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>
|
||||
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td>
|
||||
<td>{{ s.replacements || 0 }}</td>
|
||||
<td class="muted small">
|
||||
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!printers.length">
|
||||
<td colspan="7" class="muted" style="text-align:center;">
|
||||
Nothing can be estimated yet from {{ days }} days of history.
|
||||
<tr v-for="c in group.items" :key="c.printerid + c.name">
|
||||
<td class="colcolor">
|
||||
<span class="chip" :style="chipStyle(c.color)"
|
||||
:title="colorLabel(c.color)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="`/printers/${c.printerid}`">
|
||||
{{ c.printername || c.assetnumber }}
|
||||
</router-link>
|
||||
<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>
|
||||
</td>
|
||||
<td class="collevel">
|
||||
<!-- A bar reads at a glance where a percentage has to be
|
||||
compared digit by digit against the row above. -->
|
||||
<span class="bar" :title="levelText(c)">
|
||||
<span class="bar-fill" :class="c.band"
|
||||
:style="{ width: barWidth(c) }"></span>
|
||||
</span>
|
||||
<span class="level-text">{{ levelText(c) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="days" :class="c.band">{{ daysText(c) }}</span>
|
||||
</td>
|
||||
<td class="muted small">
|
||||
{{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }}
|
||||
</td>
|
||||
<td class="muted small">
|
||||
{{ c.replacements || 0 }}
|
||||
<span v-if="c.basisdays">/ {{ c.basisdays }}d</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -98,38 +135,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no
|
||||
estimate is neither urgent nor safe, and the reason is the useful part. -->
|
||||
<div v-if="unestimated.length" class="card">
|
||||
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3>
|
||||
<div class="table-container">
|
||||
<!-- 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 point. -->
|
||||
<div v-if="unestimated.length" class="card band-card">
|
||||
<button class="band-head" @click="toggle('none')">
|
||||
<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>
|
||||
<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>
|
||||
<tbody>
|
||||
<template v-for="p in unestimated" :key="p.printerid">
|
||||
<tr v-for="s in p.supplies" :key="p.printerid + s.name">
|
||||
<td>
|
||||
<router-link :to="`/printers/${p.printerid}`">
|
||||
{{ p.printername || p.assetnumber }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ s.name }}</td>
|
||||
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
|
||||
<td class="muted">{{ s.reason || '-' }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-for="c in unestimated" :key="c.printerid + c.name">
|
||||
<td class="colcolor">
|
||||
<span class="chip" :style="chipStyle(c.color)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="`/printers/${c.printerid}`">
|
||||
{{ c.printername || c.assetnumber }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td class="collevel">{{ levelText(c) }}</td>
|
||||
<td class="muted">{{ c.reason || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="muted footnote">
|
||||
{{ summary.replacements }} cartridges changed across the fleet in the
|
||||
last {{ days }} days.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
@@ -137,14 +187,117 @@ const loading = ref(true)
|
||||
const available = ref(false)
|
||||
const reason = ref('')
|
||||
const days = ref(90)
|
||||
const printers = ref([])
|
||||
const horizondays = ref(14)
|
||||
const cartridges = 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) {
|
||||
if (daysleft <= 7) return 'critical'
|
||||
if (daysleft <= 30) return 'low'
|
||||
return 'ok'
|
||||
// Only what is already empty starts open. The order list above covers the same
|
||||
// ground in a tenth of the height, so the bands are evidence, asked for.
|
||||
const openBands = ref(new Set(['empty']))
|
||||
|
||||
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() {
|
||||
@@ -154,8 +307,10 @@ async function load() {
|
||||
const data = response.data.data
|
||||
available.value = data.available
|
||||
reason.value = data.reason || ''
|
||||
printers.value = data.printers || []
|
||||
cartridges.value = data.cartridges || []
|
||||
unestimated.value = data.unestimated || []
|
||||
orderlist.value = data.orderlist || []
|
||||
horizondays.value = data.horizondays || 14
|
||||
summary.value = data.summary || summary.value
|
||||
} catch (err) {
|
||||
available.value = false
|
||||
@@ -171,21 +326,104 @@ onMounted(load)
|
||||
<style scoped>
|
||||
.actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.actions .form-control { width: auto; }
|
||||
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
||||
.summary-card {
|
||||
background: var(--bg-card-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
min-width: 12rem;
|
||||
|
||||
.order-card { padding: 0 0 0.5rem; }
|
||||
.order-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; }
|
||||
.summary-label { color: var(--text-light); font-size: 0.85rem; }
|
||||
.order-head .section-title { padding: 0.75rem 0; }
|
||||
.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.critical { color: var(--danger); }
|
||||
.days.low { color: var(--warning); }
|
||||
.days.empty { color: var(--danger); }
|
||||
.days.soon { color: var(--warning); }
|
||||
.small { font-size: 0.8rem; }
|
||||
.row-group-start td { border-top: 2px solid var(--border); }
|
||||
.empty-state { padding: 2rem; text-align: center; }
|
||||
.section-title { padding: 0.75rem 1rem 0; }
|
||||
.footnote { margin-top: 1rem; font-size: 0.85rem; }
|
||||
</style>
|
||||
|
||||
@@ -185,6 +185,76 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
|
||||
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):
|
||||
"""Days-left of the supply that runs out first, or None if none estimate.
|
||||
|
||||
|
||||
@@ -336,3 +336,128 @@ def test_a_future_checkin_cannot_mask_server_side_silence(client, db, app,
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
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
|
||||
|
||||
@@ -9,7 +9,8 @@ purchasing decision.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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)
|
||||
@@ -170,6 +171,91 @@ def test_burn_rate_needs_elapsed_time():
|
||||
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):
|
||||
"""A dashboard must be told the data is missing, not shown an empty list
|
||||
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']
|
||||
assert body['available'] is False
|
||||
assert 'not configured' in body['reason']
|
||||
assert body['printers'] == []
|
||||
assert body['cartridges'] == []
|
||||
assert body['orderlist'] == []
|
||||
|
||||
165
tests/test_plugins/test_toner_forecast_report.py
Normal file
165
tests/test_plugins/test_toner_forecast_report.py
Normal 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
|
||||
Reference in New Issue
Block a user