Two things reported from the floor, one cause each. "5 CHANGES IN 90 DAYS, THAT'S HARD TO BELIEVE." It was. A replacement was any +10 rise between readings, with no check on where it landed, so two shapes that are not swaps scored as swaps: a supply reading 0 or near-0 while it was out of the machine and then reading normally again, and a coarse gauge ticking back up after a reseat or a power cycle. A swap must now also LAND near full, because that is what a new cartridge reads, and a single dip that RECOVERS to roughly where it came from is dropped before anything looks at it. The dip filter keys on shape rather than cause, which is why it holds for all of them - a supply pulled out to be shaken, a door open mid-poll, or a site whose preprocessing maps the Printer MIB's unknown sentinels onto 0. It is NOT a Zabbix timeout: an item that does not answer records nothing rather than writing a zero. A genuine near-empty reading before a real swap does not recover, it jumps to full, so it survives and its swap still counts. find_replacements and current_run now read one predicate. When they disagreed, a phantom rise reset the run and threw away the history the estimate needed - so the bad count was quietly damaging the rate as well, which is why both were wrong at once. Expect replacement counts to FALL and per-cartridge history to lengthen. A BURST BIASED THE RATE FOR THE LIFE OF THE CARTRIDGE. The rate was the slope between the first and last reading of the run, and two endpoints cannot tell "steady" from "burst then stopped". A cartridge that lost 20 percent in two days and then barely moved for a month read as 0.83 percent/day forever after, so the report kept promising it would run out long after printing slowed. It is now the median of the per-interval rates: the burst is one interval among many rather than one of two points. Rising intervals are dropped as noise; flat ones stay in at zero, because a cartridge that did not move is real information. If every interval is flat or rising yet the run dropped overall, it falls back to the whole-run slope rather than reporting nothing. Where the intervals disagree by 5x or more the rate carries a marker and an explanation on hover. The number is still the best estimate available; the flag stops it reading as a measurement. The "Changed" column is "Replacements", and its cell says "2 in 90d" rather than "2 / 90d", which was read as a date, a ratio and a version number.
443 lines
15 KiB
Vue
443 lines
15 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 detected, over the days of history behind this row">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">
|
|
<!-- "2 / 90d" read as a date, a ratio, or a version to
|
|
everyone who saw it. Say the unit. -->
|
|
<template v-if="c.basisdays">
|
|
{{ c.replacements || 0 }} in {{ 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>
|