diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfae8b7..e1024741 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index a298add8..ff79a48b 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -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, diff --git a/plugins/geenforce/client/Register-ShopdbShadow.ps1 b/plugins/geenforce/client/Register-ShopdbShadow.ps1 index e9701289..0f3ad96b 100644 --- a/plugins/geenforce/client/Register-ShopdbShadow.ps1 +++ b/plugins/geenforce/client/Register-ShopdbShadow.ps1 @@ -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 :\\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: " 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 } diff --git a/plugins/geenforce/frontend/views/EnforcementReports.vue b/plugins/geenforce/frontend/views/EnforcementReports.vue index 0f1eeb72..414fe5b3 100644 --- a/plugins/geenforce/frontend/views/EnforcementReports.vue +++ b/plugins/geenforce/frontend/views/EnforcementReports.vue @@ -61,23 +61,33 @@ - + {{ report.machinenumber }} - {{ report.machinenumber }} + {{ report.toolassetnumber }} + {{ report.toolassetnumber }} {{ report.displayrole }} - -
{{ report.location }}
@@ -99,15 +109,19 @@ {{ report.installed }} {{ report.skipped }} {{ report.failed }} - - - - - + + + {{ report.backupkind }} + - {{ formatDate(report.lastcheckin || report.receivedat) }} @@ -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' diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 2b9ba9f4..d8046585 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -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), }, - }) + )) diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index 8d557ab1..f9b4e2e0 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -24,73 +24,110 @@