The machine page now shows the PC that controls it. The endpoint was correct all along and returned both directions; AssetTriageBand discarded every peer whose type was not 'machine' and was mounted only on the PC page. The counterpart type, label and relationship types are now props. Fixed while adopting it more widely: every band chip linked to /assets/<id>, and there is no such route. The resolver that AssetRelationships.vue kept privately is now frontend/src/utils/assetRoute.js, used by both - and its type lookup is case-insensitive, because seed data capitalises the type names while this site's database does not. The legacy import is now safe to resume. The 409-resolve asked for per_page=10000 against a server that clamps to 100, so it silently searched the first 100 rows and dropped links past that on every re-run. The crosswalk is now bound to the database it describes, so one carried across a rebuild is refused rather than attaching rows to the wrong assets. Source database names are resolved from the environment, so the loader can be aimed without editing it. Full suite 2178 passed, frontend 280 passed.
527 lines
20 KiB
Vue
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 > 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>
|