Files
shopdb-flask/plugins/printers/frontend/views/TonerForecast.vue
cproudlock 875fde9f48
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
Fix what the last round of device fixes broke, and two dips it missed
A review of d60ed60 and 8b9b936 found five things. Three were introduced by
those commits.

REFUSING A NAMED DEVICE MADE A MARKER PC CLAIM ITS OPERATION. _sync_partmarker
returned [] both for "not a marker PC" and for "a marker PC that linked
nothing", and the caller reads [] as the first - so a bay whose asset-id.txt
named something unresolvable fell through to the ordinary machine link, and for
a marker PC the machine number IS the operation. It took an active link to a
record that can only have one holder while several markers share it, and the
warning said "not linked". The previous behaviour minted a twin; this traded
that for a contested operation. None now means "not a marker PC" and is the only
answer that lets the machine link run; the response normalises it away so the
API shape is unchanged.

A DORMANT CHALLENGER WAS PROMOTED BY DELETING A FILE. Recording a challenger
dormant leaves a row that the next cycle finds as `reuse` and reactivated with
no incumbent check - so a second PC took a live device by its enrollment file
becoming unreadable. Both reuse branches re-check incumbency now, which is what
_sync_machine_link always did.

AN INCUMBENT UNDER ANOTHER COLLECTOR LABEL WAS INVISIBLE. Incumbency was queried
on our own label, but on a CMM the instrument IS the reported bay, so the
incumbent's link is the machine sync's row. A second PC naming that instrument
found no incumbent and linked actively: two live holders of one instrument, each
invisible to the other. Incumbency now counts any collector-owned label. A row
made BY HAND carries none of them and is still excluded - a person's link is not
the collector's to archive.

THE NETWORK FORM LOCKED OUT THE ROWS IT NEEDED TO FIX. Asset number is disabled
while editing, correctly, but the payload is built in script so the blank was
still sent - and the new server-side guard rejects it. A device with no asset
number could not be saved at all, and the field could not be typed into. It now
unlocks only for a record that loaded without one, with a hint saying why.

"2 in 4.0d" WAS THE LABEL LYING. Replacements are counted across the whole
history window; basisdays is only how long the current cartridge has been in.
Joining them with "in" claimed two changes inside four days - the exact shape
reported as unbelievable, except here the data was right. Now "2, this one 4.0d".

Two toner dips the same review found:

A MULTI-POLL OUTAGE STILL MINTED A PHANTOM SWAP. Only single readings were
dropped, so 90, 0, 0, 90 survived and 0 -> 90 scored as a change. Dips of any
length are handled now. One bad sample stays a candidate whatever the polling
cadence, because a reading is an instant; several consecutive low ones only
count as one outage when they are close together, since days at zero is a real
empty period. That time bound also separates an outage from a swap, ordinary
consumption, and a second swap, which have the same shape in levels alone.

THE DIP FILTER ATE REAL SWAPS OF NEARLY-FULL CARTRIDGES. Recovery was tested
with an absolute difference, so 95 then 5 then 100 read as a recovery because
100 and 95 are close, and the swap evidence was deleted. Toner only falls: a
recovery comes back at or BELOW where it left, a new cartridge comes back
higher.

The review also proved by reverting each feature that the previous tests did not
pin the median burn rate or the near-full rule - both passed with the bug
restored. Verified by the same method that all four toner behaviours now fail
when reverted, and the burst assertion is tight enough to tell 0.2 from 0.88.
2026-08-21 08:49:38 -04:00

448 lines
16 KiB
Vue

<template>
<div>
<div class="page-header">
<h1>Toner Forecast</h1>
<div class="actions">
<select v-model.number="days" class="form-control" @change="load">
<option :value="30">Last 30 days</option>
<option :value="90">Last 90 days</option>
<option :value="180">Last 180 days</option>
</select>
<router-link to="/reports/toner" class="btn btn-secondary">Toner Report</router-link>
</div>
</div>
<div v-if="loading" class="loading">Reading history...</div>
<!-- Zabbix off is not the same as nothing running out. Say which. -->
<div v-else-if="!available" class="card empty-state">
<p>{{ reason || 'Supply history is unavailable.' }}</p>
<p class="muted">
Levels and history both come from Zabbix. With it unreachable there is
nothing to forecast from - this is not a report of "nothing is due".
</p>
</div>
<template v-else>
<!-- 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>
<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>
<!-- 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>Supply</th>
<th>Part</th>
<th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th title="Cartridge changes seen anywhere in the history window, and how long the CURRENT cartridge has been in">Replacements</th>
</tr>
</thead>
<tbody>
<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">
<template v-if="c.burnrateperday != null">
{{ c.burnrateperday }}%/day<!--
--><span v-if="c.rateunstable" class="unstable"
title="This cartridge's usage has varied a lot between readings - a burst then a lull, or the reverse. The estimate is the best available, not a measurement.">~</span>
</template>
<template v-else>-</template>
</td>
<td class="muted small">
<!-- Two DIFFERENT spans, so they must not be joined by a
word that implies one contains the other. Replacements are
counted across the whole history window; basisdays is only
how long the CURRENT cartridge has been in. "2 in 4.0d"
claimed two changes within four days, which is the shape
that reads as broken data - and here it would be the
label lying, not the data. -->
<template v-if="c.basisdays">
{{ c.replacements || 0 }}, this one {{ c.basisdays }}d
</template>
<template v-else>{{ c.replacements || 0 }}</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 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 class="colcolor"></th>
<th>Printer</th><th>Supply</th><th class="collevel">Level</th><th>Why</th>
</tr>
</thead>
<tbody>
<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, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import { apiError } from '@/utils/apiError'
const loading = ref(true)
const available = ref(false)
const reason = ref('')
const days = ref(90)
const horizondays = ref(14)
const cartridges = ref([])
const unestimated = ref([])
const orderlist = ref([])
const summary = ref({ bands: {}, replacements: 0, toorder: 0 })
const copied = ref(false)
// 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() {
loading.value = true
try {
const response = await printersApi.supplyForecast(days.value)
const data = response.data.data
available.value = data.available
reason.value = data.reason || ''
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
reason.value = apiError(err, 'Failed to read supply history')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.actions { display: flex; gap: 0.5rem; align-items: center; }
.actions .form-control { width: auto; }
.order-card { padding: 0 0 0.5rem; }
.order-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 1rem;
}
.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.empty { color: var(--danger); }
.days.soon { color: var(--warning); }
.small { font-size: 0.8rem; }
/* Marks a rate the intervals do not agree on. Deliberately quiet - it qualifies
the number beside it rather than competing with the urgency bands. */
.unstable { margin-left: 2px; font-weight: 600; cursor: help; }
.empty-state { padding: 2rem; text-align: center; }
.footnote { margin-top: 1rem; font-size: 0.85rem; }
</style>