Files
shopdb-flask/plugins/printers/frontend/views/PrinterDetail.vue
cproudlock c22c81afa5
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m7s
Show the controls link from both ends, and fix the dead chip links
THE RECIPROCAL LINKS BUG

A PC's page showed "Serves machine 7803" at the top. The machine's page showed
nothing equivalent, though the same edge was right there in the relationships
card at the bottom. That asymmetry read as missing data and was reported as
such.

It was neither missing data nor an API fault. The endpoint returns both
directions and always did; AssetTriageBand already walked outgoing AND incoming
and deduped, with a comment explaining that production holds reciprocal rows.
It then discarded every peer whose asset type was not 'machine', and hardcoded
the label "Serves machine". So the component could only ever work from the PC
side, and it was mounted only on the PC page.

The counterpart type and label are now props, defaulting to the old values so
the PC page is unchanged. The machine page mounts the band with
counterparttype="computer" and the label "Controlled by".

Eliminated with evidence rather than assumed, because this bug had already been
misdiagnosed twice: the endpoint runs two queries and returns both arrays; the
generic card renders both; that route has no pagination to clamp; and a single
permission gate cannot admit one direction and not the other.

THE RELATIONSHIP TYPE WAS ALSO HARDCODED

Not every asset pair is linked by 'controls'. A machine reaches a printer
through usesprinter and defaultprinter, so the band was blank on the printer
page - the one page where the link is not obvious from the asset itself. The
types are now a prop, and the printer page passes its own.

THE CHIPS WERE DEAD LINKS

Found while adopting the band more widely. It linked to /assets/<assetid>.
There is no /assets/:id route and no catch-all, so every chip in the band was
dead - and this change would have propagated that to four more pages.

AssetRelationships.vue already resolved this correctly and privately. That
function is now frontend/src/utils/assetRoute.js and both components use it, so
a third caller cannot get it wrong again. Two things it gets right that are easy
to miss: the detail routes key on pluginid, not assetid, so passing assetid
opens the wrong record; and the lookup is now case-insensitive, because
csvimport.py seeds 'Computer' and 'Machine' capitalised while this site's
database holds them lowercase - a case-sensitive match produced dead links at
whichever site seeded the other way.

The band now carries pluginid and assettypename on each chip, which the
resolver needs.

TESTS

assetRoute had none and neither did the band. Seven cover the resolver,
including the assetid-vs-pluginid confusion and the seeding-case difference.

On the backend, nothing asserted that the TARGET of a directional edge receives
it in `incoming` - only that rows carry isdirectional. That missing assertion is
this bug's exact shape, so it is now pinned: the endpoint was right all along,
and the next such report should not cost anyone a re-diagnosis of the API.

Verified on screen: the machine page shows "Controlled by G6K76CW3ESF" in the
hero, the PC page is unchanged, and a page with no such link renders no empty
band. Full suite 2162 passed, frontend 280 passed.
2026-08-29 18:58:06 -04:00

527 lines
20 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>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<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>
<!-- The 6am band: which machine stops if this printer is down.
A shop-floor printer is often a label or tag printer feeding one
machine, and that link was only visible in the relationships
card near the bottom of the page. -->
<AssetTriageBand
:assetid="printer.assetid"
counterparttype="machine"
:relationshiptypes="['usesprinter', 'defaultprinter']"
/>
</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 &gt; 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, useRouter } from 'vue-router'
import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import AssetTriageBand from '@/components/AssetTriageBand.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'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
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
// the page's own heading logic, which already discards the literal
// name 'NONE' that a discovered queue can carry
setRecordTitle(displayTitle.value)
// 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()
}
// Retire, not delete: the server deactivates the asset, so the printer
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = displayTitle.value || 'this printer'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await printersApi.remove(route.params.id)
toast.success(`Retired ${label}`)
router.push('/printers')
} catch (error) {
console.error('Error retiring printer:', error)
toast.error(apiError(error, 'Failed to retire printer'))
}
}
</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>