Reported as "CSF04 now reports all 0%", on the printer's own page as well as
the reports. Every supply on one host reading zero at the same instant is not
what a set of cartridges does; it is what a host that stopped answering looks
like.
getsuppliesbyip turned every unreadable item into 0:
try:
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
An item that has never collected returns lastvalue as an EMPTY STRING, so
float('') raised and the handler substituted zero. lastclock was fetched in the
same request and read by nothing at all, so a reading from three weeks ago was
presented as current.
Zero is a legitimate level meaning spent, which is what made the substitution
dangerous rather than merely wrong. Downstream it reached `level <=
EMPTY_LEVEL`, which set daysleft to 0, which put the printer on the order list:
the report asked purchasing to buy a full set of cartridges for a printer
nobody had measured. On a waste container it was worse - waste is scored as
100 - level, so an unreadable one read as 100% full, the alarm state.
_readlevel now returns (level, lastseen) with level None for no usable reading:
empty, missing or unparseable lastvalue, or a lastclock older than 24h. A
lastclock that is absent is not treated as stale - a reading that cannot be
aged is not thereby wrong, and discarding it would lose real levels. A genuine
"0" is still 0, which is the half that keeps an actually-empty cartridge on the
order list.
classifysupply gains an 'unknown' status with remaining None. It is not
critical: critical is a claim someone acts on, and it may not be made about a
supply nobody measured.
The low-supplies report skips unknown rather than listing it as needing
replacement, and counts it in summary.unknown - a printer that has gone quiet
should be visible as that, not absent and not masquerading as empty.
_annotate_supply reads .get('level') with no 0 default, which would have put
the whole bug back.
PrinterDetail already rendered `level !== null ? ... : 'N/A'`, so it starts
telling the truth as soon as the backend sends null; the UI had been written
for a case the service never produced. It gains the unknown style - muted, not
a severity colour - and a tooltip separating "no reading at all" from "no
reading since <date>", which are different faults with different fixes.
Alerts needed no change: float(None) raises and that loop already continues,
so unknown supplies stop firing low-toner emails as a consequence.
12 of the 16 new tests fail on the old code. The 4 that pass either way pin
that real zeros still behave, which is the regression this fix could cause.
Not verified against CSF04's actual Zabbix data - dev has no reachable Zabbix.
This fixes the mechanism that turns unknown into 0%; /api/printers/<id>/supplies
now distinguishes them, with null for no reading and 0 for a measured zero.
490 lines
19 KiB
Vue
490 lines
19 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Printer Details</h2>
|
|
<div class="header-actions">
|
|
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
|
Print QR
|
|
</router-link>
|
|
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
|
Print Label
|
|
</router-link>
|
|
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
|
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="printer">
|
|
<!-- Hero Section -->
|
|
<div class="hero-card">
|
|
<div class="hero-image" v-if="printer.printer?.imageurl">
|
|
<img :src="withBase(printer.printer.imageurl)" :alt="printer.printer.modelname || 'Model photo'" />
|
|
</div>
|
|
<div class="hero-content">
|
|
<div class="hero-title">
|
|
<h1>{{ displayTitle }}</h1>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-lg badge-printer">Printer</span>
|
|
<span v-if="printer.printer?.iscsf" class="badge badge-lg badge-info">CSF</span>
|
|
<span v-if="printer.printer?.iscolor" class="badge badge-lg badge-success">Color</span>
|
|
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
|
|
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
|
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
|
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
|
</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<div class="hero-detail" v-if="printer.printer?.vendorname">
|
|
<span class="hero-detail-label">Vendor</span>
|
|
<span class="hero-detail-value">
|
|
{{ printer.printer.vendorname }}
|
|
<!-- Inherited from the catalog model, not stored on this record. -->
|
|
<small v-if="printer.printer?.vendorfrommodel" class="text-muted">(from model)</small>
|
|
</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="printer.printer?.modelname">
|
|
<span class="hero-detail-label">Model</span>
|
|
<span class="hero-detail-value">{{ printer.printer.modelname }}</span>
|
|
</div>
|
|
<!-- Only when it DIFFERS from the printer's own type: the two are
|
|
separate tables but carry the same names in practice. -->
|
|
<div class="hero-detail"
|
|
v-if="printer.printer?.modeltypename && printer.printer.modeltypename !== printer.printer?.printertypename">
|
|
<span class="hero-detail-label">Model type</span>
|
|
<span class="hero-detail-value">{{ printer.printer.modeltypename }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="printer.serialnumber">
|
|
<span class="hero-detail-label">Serial Number</span>
|
|
<span class="hero-detail-value mono">{{ printer.serialnumber }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="ipAddress">
|
|
<span class="hero-detail-label">IP Address</span>
|
|
<span class="hero-detail-value mono">{{ ipAddress }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
|
<!-- Main Content Grid -->
|
|
<div class="content-grid">
|
|
<!-- Left Column -->
|
|
<div class="content-column">
|
|
<!-- Identity Section -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Identity</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Asset Number</span>
|
|
<span class="info-value mono">{{ printer.assetnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.printer?.windowsname">
|
|
<span class="info-label">Windows Name</span>
|
|
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('fqdn', 'printer') && printer.printer?.hostname">
|
|
<span class="info-label">Hostname / FQDN</span>
|
|
<span class="info-value mono">{{ printer.printer.hostname }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.printer?.sharename">
|
|
<span class="info-label">CSF Share Name</span>
|
|
<span class="info-value mono">{{ printer.printer.sharename }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.serialnumber">
|
|
<span class="info-label">Serial Number</span>
|
|
<span class="info-value mono">{{ printer.serialnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('gaugelabreference', 'printer') && printer.gaugelabreference">
|
|
<span class="info-label">Gauge Lab Reference</span>
|
|
<span class="info-value mono">{{ printer.gaugelabreference }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('maintenancereference', 'printer') && printer.maintenancereference">
|
|
<span class="info-label">Maintenance Reference</span>
|
|
<span class="info-value mono">{{ printer.maintenancereference }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Printer Settings -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Printer Settings</h3>
|
|
<div class="info-list">
|
|
<div class="info-row" v-if="printer.printer?.installpath">
|
|
<span class="info-label">Install Path</span>
|
|
<span class="info-value mono">{{ printer.printer.installpath }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Color</span>
|
|
<span class="info-value">{{ printer.printer?.iscolor ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Duplex</span>
|
|
<span class="info-value">{{ printer.printer?.isduplex ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Network Printer</span>
|
|
<span class="info-value">{{ printer.printer?.isnetwork ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">CSF Printer</span>
|
|
<span class="info-value">{{ printer.printer?.iscsf ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.printer?.pin">
|
|
<span class="info-label">PIN</span>
|
|
<span class="info-value mono">{{ printer.printer.pin }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="content-column">
|
|
<!-- Network -->
|
|
<div class="section-card" v-if="printer.communications?.length">
|
|
<h3 class="section-title">Network</h3>
|
|
<div class="network-list">
|
|
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
|
<div class="network-primary">
|
|
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
</div>
|
|
<div class="network-secondary" v-if="comm.macaddress">
|
|
<span class="mac-address">{{ comm.macaddress }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location & Organization -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Location & Organization</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Map Location</span>
|
|
<span class="info-value">
|
|
<LocationMapTooltip
|
|
v-if="printer.mapx != null && printer.mapy != null"
|
|
:left="printer.mapx"
|
|
:top="printer.mapy"
|
|
:levelid="printer.levelid"
|
|
:machineName="printer.name || printer.assetnumber"
|
|
>
|
|
<span class="location-link">View on Map</span>
|
|
</LocationMapTooltip>
|
|
<span v-else>Not mapped</span>
|
|
</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.businessunitname">
|
|
<span class="info-label">Business Unit</span>
|
|
<span class="info-value">{{ printer.businessunitname }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Fields -->
|
|
<CustomFieldsSection :assetid="printer.assetid" />
|
|
|
|
<!-- Warranty -->
|
|
<PluginAssetPanels :assetid="printer.assetid" />
|
|
|
|
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
|
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
|
|
|
|
<!-- Notes -->
|
|
<div class="section-card" v-if="printer.notes">
|
|
<h3 class="section-title">Notes</h3>
|
|
<p class="notes-text">{{ printer.notes }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Supplies Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Supplies</h3>
|
|
</div>
|
|
|
|
<div v-if="supplies.length === 0" class="empty-state">
|
|
No supply information available
|
|
</div>
|
|
|
|
<div v-else class="supplies-grid">
|
|
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
|
|
<div class="supply-header">
|
|
<span class="supply-name">{{ supply.name }}</span>
|
|
<!-- N/A is not the same as 0%. A supply Zabbix has no usable
|
|
reading for arrives with level null and status 'unknown';
|
|
the title says which of the two reasons it is, because
|
|
"no data" and "last seen three weeks ago" need different
|
|
people to fix them. -->
|
|
<span class="supply-level" :class="supply.status"
|
|
:title="supply.status === 'unknown' ? unknownReason(supply) : null">
|
|
{{ supply.level !== null && supply.level !== undefined ? `${supply.level}%` : 'N/A' }}
|
|
</span>
|
|
</div>
|
|
<div class="supply-bar">
|
|
<div
|
|
class="supply-bar-fill"
|
|
:class="supply.status"
|
|
:style="{ width: `${supply.level || 0}%` }"
|
|
></div>
|
|
</div>
|
|
<div class="supply-meta">
|
|
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste && supply.remaining !== null"> ({{ supply.remaining }}% remaining)</template></span>
|
|
<span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
|
|
<span
|
|
v-for="part in supply.partnumbers"
|
|
:key="part.partnumber"
|
|
class="supply-part"
|
|
:data-tip="`Part ${part.partnumber}` + (part.pageyield ? ` - ${part.pageyield} pages` : '')"
|
|
>{{ part.marketingname || part.partnumber }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drivers Card: pulled from the driver catalog by this printer's model -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Drivers</h3>
|
|
</div>
|
|
|
|
<div v-if="drivers.length === 0" class="empty-state">
|
|
No drivers for this model. Add one under Settings > Printer Drivers and
|
|
link it to this printer's model.
|
|
</div>
|
|
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Location</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="driver in drivers" :key="driver.driverid">
|
|
<td><strong>{{ driver.name }}</strong></td>
|
|
<td>
|
|
<a v-if="isHttp(driver.location)" :href="driver.location" target="_blank" class="mono">{{ driver.location }}</a>
|
|
<span v-else class="mono">{{ driver.location }}</span>
|
|
</td>
|
|
<td>{{ driver.description || '-' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Footer -->
|
|
<div class="audit-footer">
|
|
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
|
|
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<p style="text-align: center; color: var(--text-light);">Printer not found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { printersApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
|
|
const route = useRoute()
|
|
const { isEnabled } = useIdentifierFlags()
|
|
|
|
const loading = ref(true)
|
|
const printer = ref(null)
|
|
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => printer.value?.assetid)
|
|
const supplies = ref([])
|
|
const drivers = ref([])
|
|
|
|
// Best display identifier for a printer. name is often the literal "NONE",
|
|
// so fall back to the Windows name, then hostname, then asset number.
|
|
const displayTitle = computed(() => {
|
|
const p = printer.value
|
|
if (!p) return ''
|
|
const name = (p.name || '').trim()
|
|
if (name && name.toUpperCase() !== 'NONE') return name
|
|
return p.printer?.windowsname || p.printer?.hostname || p.assetnumber
|
|
})
|
|
|
|
function isHttp(loc) {
|
|
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
|
|
}
|
|
|
|
// Get IP address from communications
|
|
const ipAddress = computed(() => {
|
|
if (!printer.value?.communications) return null
|
|
const primaryComm = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
|
|
return primaryComm?.ipaddress || null
|
|
})
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const [printerRes, suppliesRes] = await Promise.all([
|
|
printersApi.get(route.params.id),
|
|
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } }))
|
|
])
|
|
|
|
printer.value = printerRes.data.data
|
|
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
|
|
supplies.value = suppliesRes.data.data?.supplies || []
|
|
// drivers are attached to the printer detail, matched by model
|
|
drivers.value = printerRes.data.data?.drivers || []
|
|
} catch (error) {
|
|
console.error('Error loading printer:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
function getStatusClass(status) {
|
|
if (!status) return 'badge-info'
|
|
const s = status.toLowerCase()
|
|
if (s === 'in use' || s === 'active' || s === 'online') return 'badge-success'
|
|
if (s === 'in repair' || s === 'offline') return 'badge-warning'
|
|
if (s === 'retired' || s === 'error') return 'badge-danger'
|
|
return 'badge-info'
|
|
}
|
|
|
|
function formatSupplyType(supplytype) {
|
|
if (!supplytype) return ''
|
|
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
|
|
}
|
|
|
|
// Why a supply reads N/A. lastseen is the epoch of the newest reading Zabbix
|
|
// holds: absent means the item has never collected, present means it has but
|
|
// the reading is too old to present as current. Those are different faults and
|
|
// the tooltip is the only place either one is stated.
|
|
function unknownReason(supply) {
|
|
if (!supply.lastseen) return 'Zabbix has no reading for this supply'
|
|
return `No reading since ${new Date(supply.lastseen * 1000).toLocaleString()}`
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Printer-specific styles - shared styles are in global style.css */
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
color: var(--text-light);
|
|
padding: 2.5rem;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
/* Supplies */
|
|
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
|
|
|
|
.supplies-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.supply-item {
|
|
background: var(--bg);
|
|
padding: 1.25rem;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.supply-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.supply-name {
|
|
font-weight: 500;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
.supply-level {
|
|
font-weight: 600;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
.supply-level.ok { color: var(--success); }
|
|
.supply-level.low { color: var(--warning); }
|
|
.supply-level.critical { color: var(--danger); }
|
|
/* Muted on purpose. An unknown level is not a severity - colouring it like one
|
|
would put it in the same reading as critical, which is the confusion this
|
|
whole change exists to end. The help cursor points at the tooltip. */
|
|
.supply-level.unknown { color: var(--text-light); cursor: help; }
|
|
|
|
.supply-bar {
|
|
height: 10px;
|
|
background: var(--border);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.supply-bar-fill {
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
background: var(--success);
|
|
}
|
|
|
|
.supply-bar-fill.low { background: var(--warning); }
|
|
.supply-bar-fill.critical { background: var(--danger); }
|
|
|
|
.supply-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
margin-top: 0.625rem;
|
|
}
|
|
|
|
.supply-parts {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.supply-part {
|
|
cursor: help;
|
|
border-bottom: 1px dotted var(--text-light);
|
|
position: relative;
|
|
}
|
|
|
|
/* instant CSS tooltip, no native title delay */
|
|
.supply-part:hover::after {
|
|
content: attr(data-tip);
|
|
position: absolute;
|
|
bottom: 125%;
|
|
right: 0;
|
|
white-space: nowrap;
|
|
background: var(--text);
|
|
color: var(--bg-card);
|
|
padding: 0.35rem 0.6rem;
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|