Four defects stacked into one nonsense report: cartridges at 20% claiming four days, cartridges at 1% claiming weeks. The root cause is a Zabbix API detail. `limit` caps the whole result set rather than each item, and the query sorted ascending, so the cap kept the OLDEST rows in the window. A four-cartridge printer polled every five minutes writes over 100k readings in 90 days; the forecast was fitted to the first few days of that and nothing since. Every rate was real and every rate described a cartridge thrown away three months ago. Nothing in the output looks wrong, which is why it needed pinning in a test rather than a comment. A 90-day burn rate does not need every individual poll, so a long window now reads hourly trends - the table meant for this, a tenth of the rows, and kept longer. Raw history serves short windows and any item a site keeps no trends for. Both are fetched newest-first with the budget scaled per item. Second, the countdown was computed from the last stored reading while the level displayed was the live one, so the two could disagree by a whole cartridge. The live level is now what the countdown divides. A live level far above the stored run means it was swapped since the last reading, and that is reported as a replacement rather than as a collapse in the burn rate. Third, at or below 5% a cartridge reads as empty rather than as a slow drain. At 1% losing a tenth of a point a day the arithmetic says ten days. The printer is out of toner, and it is the first thing to order. Fourth, the days-left column spanned the printer's rows, so the printer's soonest figure was printed beside every supply it had. That alone accounts for the shape of both complaints: a healthy cartridge wearing its neighbour's deadline, and an empty one wearing a number that belonged to nothing on its row. Also fixes float-typed supplies vanishing from any printer that also had an integer-typed one - they live in different history tables and the fetch stopped at whichever answered first. Not verified against live data: Zabbix is not reachable from the dev box.
192 lines
7.1 KiB
Vue
192 lines
7.1 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>
|
|
<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.
|
|
|
|
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. -->
|
|
<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>
|
|
<span v-if="s.daysleft === 0" class="days critical">empty now</span>
|
|
<span v-else-if="s.daysleft != null" class="days" :class="urgency(s.daysleft)">
|
|
{{ s.daysleft }} days
|
|
</span>
|
|
<span v-else class="muted">-</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>
|