diff --git a/CHANGELOG.md b/CHANGELOG.md index 591fbe8b..bbfae8b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,33 @@ each fix ships with the check, report or document that would have surfaced it. ### Fixed +- The toner forecast was reading the wrong end of the window. Zabbix applies a + query's `limit` to the whole answer rather than to each item, and combined + with an ascending sort that kept the OLDEST rows: a four-cartridge printer + polled every five minutes writes over 100k readings in 90 days, so a "90 day" + forecast was fitted to the first few days of the window and nothing since. + That is where "4 days left" beside a cartridge at 20% came from - the rate + was real, it just described a cartridge thrown away in May. Long windows now + read hourly trends, which is the table meant for this and is a tenth of the + rows; short ones read raw history, newest first, with a budget scaled per + item. +- The forecast now counts down from the level it displays. It showed the live + reading but computed days-left from the last stored one, so the two could + disagree by a whole cartridge. A live level far above the stored run is + treated as a swap that happened since the last reading rather than as a + collapse in the burn rate. +- A cartridge at or below 5% reads as empty rather than as a slow drain. At 1% + losing a tenth of a point a day the arithmetic said ten days; the printer is + out of toner, and it is the first thing that should be ordered. +- Days-left in the forecast is per cartridge again. It was rendered in a cell + spanning the printer's rows, which put the printer's soonest figure beside + every supply it had - a cartridge at 20% displaying "4 days" that belonged to + the black beside it, and a cartridge at 1% displaying weeks that belonged to + nothing on that row at all. +- Supplies typed as floats were dropped from the forecast on any printer that + also had an integer-typed one. The two live in different Zabbix history + tables and the fetch stopped at whichever answered first, so half a printer's + cartridges silently had no history at all. - A shop-floor PC that reported the machine number of a machine ShopDB already knew got a 500 from the collector, on every report, forever. The reported machine number was being written to the PC's own `assets.assetnumber`, which diff --git a/plugins/geenforce/client/Register-ShopdbShadow.ps1 b/plugins/geenforce/client/Register-ShopdbShadow.ps1 new file mode 100644 index 00000000..e9701289 --- /dev/null +++ b/plugins/geenforce/client/Register-ShopdbShadow.ps1 @@ -0,0 +1,79 @@ +# Register-ShopdbShadow.ps1 -- put this bay into shopdb SHADOW mode. +# +# Shadow mode = fetch the shopdb manifest, diff it against the share manifest, +# report the cycle to shopdb, and install FROM THE SHARE exactly as today. Zero +# behaviour change. It is the observable step before any cutover. +# +# Runs as SYSTEM under GE-Enforce, from a manifest entry gated to one hostname. +# Idempotent: re-registers the task each cycle so drift self-heals, and writes +# BaseUrl only when it differs. +# +# WHY the share manifest is read through the mounted drive: GE-Enforce.ps1 +# mounts the SFLD share with SFLD credentials before invoking the engine, and +# this script runs inside that window. SYSTEM has no standing access to the UNC +# path, so the mounted drive is the only path that resolves. The drive letter is +# not fixed, so it is derived from where this script is running rather than +# hardcoded. + +$ErrorActionPreference = 'Continue' + +$TaskName = 'ShopDB GE-Enforce (shadow)' +$InstallDir = 'C:\Program Files\GE\Shopfloor' +$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' +$Scope = 'gea-shopfloor-nocollections' + +function Write-ShadowLog { + param([string]$Message) + $line = "[{0}] [shadow-setup] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message + Write-Host $line +} + +try { + # --- BaseUrl (the client reads this; no token needed on an allowlisted subnet) + $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + $current = (Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl + if ($current -ne $BaseUrl) { + Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl + Write-ShadowLog "BaseUrl set to $BaseUrl" + } + + $runner = Join-Path $InstallDir 'Invoke-ShopdbEnforce.ps1' + $engine = Join-Path $InstallDir 'lib\Install-FromManifest.ps1' + foreach ($p in @($runner, $engine)) { + if (-not (Test-Path -LiteralPath $p)) { + Write-ShadowLog "MISSING $p - the File entries have not landed yet; will retry next cycle." + exit 0 # fail-safe: never break the bay, the entry re-runs + } + } + + # --- the share manifest this scope is enforced from, via the mounted drive. + # $PSScriptRoot is :\\shopdb-client, so its grandparent is the + # scope dir. Deriving it keeps this correct whatever letter GE-Enforce mounted. + $scopeDir = Split-Path -Parent $PSScriptRoot + $shareManifest = Join-Path $scopeDir 'manifest.json' + if (-not (Test-Path -LiteralPath $shareManifest)) { + Write-ShadowLog "share manifest not found at $shareManifest - not registering." + exit 0 + } + + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Scope "{1}" -EnginePath "{2}" -ShadowMode -ShareManifestPath "{3}"' ` + -f $runner, $Scope, $engine, $shareManifest + + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments + # RepetitionInterval ALONE - passing RepetitionDuration serializes to a + # Duration the Task Scheduler schema rejects. + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15) + $principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Principal $principal -Settings $settings -Force | Out-Null + Write-ShadowLog "registered '$TaskName' (every 15 min), shadowing $shareManifest" + exit 0 +} +catch { + # Fail-safe: a broken setup script must never stop the bay enforcing. + Write-ShadowLog "FAILED: $_" + exit 0 +} diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index c9796099..2b9ba9f4 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1541,16 +1541,18 @@ def supplies_forecast(): if not supplies: continue itemids = [s['itemid'] for s in supplies if s.get('itemid')] - history = service.gethistory(itemids, days=days) + history = service.getlevelhistory(itemids, days=days) analysed = [] for supply in supplies: points = history.get(str(supply.get('itemid')), []) - detail = analyse(points) + # The live read is the level the report shows, so it is also the + # level the countdown is computed from - history lags a poll, and a + # row whose level and days-left came from different moments reads + # as broken. + detail = analyse(points, currentlevel=supply.get('level')) detail['name'] = supply.get('name') detail['color'] = supply.get('color') - # Trust the live read for the level; history can lag a poll behind. - detail['currentlevel'] = supply.get('level', detail['currentlevel']) analysed.append(detail) entry = { diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index e42ac0f7..8d557ab1 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -55,7 +55,14 @@ + 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. -->