Files
shopdb-flask/plugins/network/frontend/views/NetworkDeviceDetail.vue
cproudlock ed5d64fa3e Name the record in the browser tab, not just the section
Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two
different machines were indistinguishable, a bookmark said nothing about what
it pointed at, and a link shared into Teams previewed as the section rather
than the thing.

The router cannot fix this alone: afterEach runs before the view has fetched
anything, so it only ever knows the KIND of page. So a detail view names its
record once the record arrives, and both writers go through
composables/pageTitle.js.

THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build
the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that
fetched quickly and would overwrite the good title with the generic one. The
route's fullPath is claimed before that await, and the generic write stands
down when a record title already belongs to the route being landed on. The
reverse case is handled too: a view that names its record BEFORE the facility
name arrives composed against the placeholder prefix, so the late resolve
rewrites it rather than leaving "ShopDB - CSF16" on a site called West
Jefferson. Both are pinned by tests, because both fail only under timing.

Covers machines, PCs, printers, network devices, subnets, measuring tools, USB
devices, printed parts, applications, employees, knowledge base articles and
guides. Printers reuse the page's own displayTitle, which already discards the
literal name "NONE" a discovered queue can carry, so the tab and the heading
agree.

A record with no usable name, or one that failed to load, keeps the section
name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs.
Long names are cut at a word boundary - knowledge base articles are named with
a whole sentence of description, which a browser otherwise truncates mid-word.

Reports gained meta.title, which the router already supported and nothing used.
That also fixes "Pc Relationships", which the name-derived spelling produced.
2026-08-26 13:13:30 -04:00

403 lines
14 KiB
Vue

<template>
<div class="detail-page" v-if="device">
<div class="hero-card">
<div class="hero-image">
<img v-if="device.networkdevice?.imageurl" :src="withBase(device.networkdevice.imageurl)" alt="Model photo" />
<div class="device-icon" v-else>
<span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
</div>
</div>
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
{{ device.networkdevice.networkdevicetypename }}
</span>
<span v-if="device.networkdevice?.vendorname" class="meta-item">
{{ device.networkdevice.vendorname }}
</span>
<span v-if="heroWarranty" class="badge" :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>
<!-- hero-detail / -label / -value are the styled classes every other
detail page uses. This page had its own detail-item / label / value
names, which match nothing in the stylesheet, so the label and the
value rendered as one run-together string. -->
<div class="hero-details">
<div class="hero-detail" v-if="device.assetnumber">
<span class="hero-detail-label">Asset #</span>
<span class="hero-detail-value">{{ device.assetnumber }}</span>
</div>
<div class="hero-detail" v-if="device.serialnumber">
<span class="hero-detail-label">Serial</span>
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
</div>
<div class="hero-detail" v-if="device.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ device.locationname }}</span>
</div>
<div class="hero-detail" v-if="device.businessunitname">
<span class="hero-detail-label">Business Unit</span>
<span class="hero-detail-value">{{ device.businessunitname }}</span>
</div>
</div>
<div class="hero-features" v-if="device.networkdevice">
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
<span v-if="device.networkdevice.portcount" class="feature-badge ports">{{ device.networkdevice.portcount }} Ports</span>
</div>
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<div class="content-grid">
<div class="content-column">
<!-- Identity -->
<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">{{ device.assetnumber }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ device.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ device.serialnumber || '-' }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'network_device') && device.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ device.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'network_device') && device.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ device.maintenancereference }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">
{{ device.networkdevice?.vendorname || '-' }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="device.networkdevice?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="info-row" v-if="device.networkdevice?.modelname">
<span class="info-label">Model</span>
<span class="info-value">{{ device.networkdevice.modelname }}</span>
</div>
<!-- Only when it DIFFERS from the device's own type: the two are
separate tables but carry the same names in practice. -->
<div class="info-row"
v-if="device.networkdevice?.modeltypename && device.networkdevice.modeltypename !== device.networkdevice?.networkdevicetypename">
<span class="info-label">Model type</span>
<span class="info-value">{{ device.networkdevice.modeltypename }}</span>
</div>
<div class="info-row">
<span class="info-label">Device Type</span>
<span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
</div>
</div>
</div>
<!-- Network Information (type-specific) -->
<div class="section-card">
<h3 class="section-title">Network Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<!-- 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">Location</span>
<span class="info-value">{{ device.locationname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ device.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<PluginAssetPanels :assetid="device.assetid" />
<!-- Relationships -->
<AssetRelationships
v-if="device.assetid"
:assetid="device.assetid"
/>
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ device.notes }}</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>
<button @click="confirmDelete" class="btn btn-danger">
Delete Device
</button>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(device.createddate) }}<template v-if="device.createdby"> by {{ device.createdby }}</template></span>
<span>Modified {{ formatDate(device.modifieddate) }}<template v-if="device.modifiedby"> by {{ device.modifiedby }}</template></span>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading device...</div>
</div>
<div v-else class="error-container">
<p>Device not found</p>
<router-link to="/network" class="btn btn-secondary">Back to Network Devices</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { networkApi } from '@/api'
import { withBase } from '@/utils/basePath'
import AssetRelationships from '@/components/AssetRelationships.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const deviceId = route.params.id
const device = ref(null)
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => device.value?.assetid)
const loading = ref(true)
onMounted(async () => {
await loadDevice()
})
async function loadDevice() {
loading.value = true
try {
const response = await networkApi.get(deviceId)
device.value = response.data.data
setRecordTitle(device.value?.assetnumber || device.value?.name)
} catch (error) {
console.error('Error loading device:', error)
device.value = null
} finally {
loading.value = false
}
}
function getDeviceIcon() {
const type = device.value?.networkdevice?.networkdevicetypename?.toLowerCase() || ''
if (type.includes('switch')) return Network
if (type.includes('router')) return Router
if (type.includes('firewall')) return Shield
if (type.includes('access point') || type.includes('ap')) return Wifi
if (type.includes('camera')) return Camera
if (type.includes('server')) return Server
if (type.includes('idf') || type.includes('closet')) return Rack
return Globe
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
async function confirmDelete() {
if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
try {
await networkApi.delete(deviceId)
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
toast.error('Failed to delete device')
}
}
}
</script>
<style scoped>
.hero-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
}
.hero-title {
margin: 0;
}
.device-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: 8px;
}
.device-icon .icon {
font-size: 4rem;
}
.hero-features {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.feature-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
font-size: 0.875rem;
border-radius: 5px;
font-weight: 500;
}
.feature-badge.poe {
background: #d4edda;
color: #155724;
}
.feature-badge.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-badge.ports {
background: var(--bg);
color: var(--text-light);
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.notes-content {
white-space: pre-wrap;
color: var(--text);
line-height: 1.6;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-badge.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-badge.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>