Forecast when a printer runs out, and count what it has been through
The toner report says what is empty now. It could not say what to order, and nothing recorded how fast anything drains - every level read was cached for five minutes and then discarded. Zabbix has been keeping the history all along; we simply never asked. One history.get gives both answers, because a cartridge only goes DOWN while it is in use: a rise is a replacement. Count the rises and you have how many cartridges a printer has been through; fit a slope to the readings SINCE the last rise and you have days-to-empty. Fitting across a replacement averages a spent cartridge with a fresh one and describes neither. Sorted by days left, which is the point. A cartridge at 60% dropping 5% a day needs ordering before one sitting at 8% that has not moved in months, and a level-sorted list ranks those backwards. It refuses to guess. Too few readings, a level that has not moved enough - many printers report in 10% steps and sit on a plateau for a fortnight - or a recent replacement each produce no estimate and say which. Those printers are listed separately rather than sorted in as 0 or as 999, since a printer without an estimate is neither urgent nor safe. Estimates show what they rest on, because "9 days from 21 days of readings" and "9 days from 2 readings" are not the same claim. A separate report card, not an extension of the toner report: that one is an exceptions list a tech acts on today, this is an ordering view read monthly, and the history query is heavier than the live read it would have slowed down. The analysis is pure arithmetic over a list of readings, so the 14 tests cover the noise wobble, the plateau, the swap, junk rows and division by zero without needing Zabbix. Zabbix being unreachable is reported as such rather than rendering an empty table that reads as "nothing is due".
This commit is contained in:
@@ -8,6 +8,12 @@ export default [
|
||||
component: () => import('./views/TonerReport.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner-forecast',
|
||||
name: 'toner-forecast',
|
||||
component: () => import('./views/TonerForecast.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printertypes',
|
||||
name: 'printer-types',
|
||||
|
||||
182
plugins/printers/frontend/views/TonerForecast.vue
Normal file
182
plugins/printers/frontend/views/TonerForecast.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<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>
|
||||
<div class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span class="summary-number">{{ summary.duewithin30 }}</span>
|
||||
<span class="summary-label">due within 30 days</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer</th>
|
||||
<th>Runs out in</th>
|
||||
<th>Supply</th>
|
||||
<th>Level</th>
|
||||
<th>Burn 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. -->
|
||||
<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 v-if="index === 0" :rowspan="p.supplies.length">
|
||||
<span class="days" :class="urgency(p.daysleft)">
|
||||
{{ p.daysleft }} days
|
||||
</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.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Printer</th><th>Supply</th><th>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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, 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 printers = ref([])
|
||||
const unestimated = ref([])
|
||||
const summary = ref({ duewithin30: 0, estimated: 0, replacements: 0 })
|
||||
|
||||
function urgency(daysleft) {
|
||||
if (daysleft <= 7) return 'critical'
|
||||
if (daysleft <= 30) return 'low'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
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 || ''
|
||||
printers.value = data.printers || []
|
||||
unestimated.value = data.unestimated || []
|
||||
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; }
|
||||
.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;
|
||||
}
|
||||
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; }
|
||||
.summary-label { color: var(--text-light); font-size: 0.85rem; }
|
||||
.days { font-weight: 700; }
|
||||
.days.critical { color: var(--danger); }
|
||||
.days.low { 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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user