Files
shopdb-flask/plugins/network/frontend/views/NetworkDeviceDetail.vue
cproudlock 3f320fcc8b
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Derive an asset's vendor from its catalog model, and show the model's own type
An asset that carries a model but no vendor was showing a blank the database
could already answer: the model records its vendor, and both sides reference the
same vendors table. Machines, PCs, printers and network devices now fall back to
it.

The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and
the detail pages render "(from model)" beside the value, because the record
itself is still empty: the edit form shows an empty vendor box, and a page
implying the vendor is stored would be lying about where it came from.

The model's type is exposed under its own name, modeltypename, and shown as a
separate "Model type" row. It is deliberately NOT used to fill in the asset's
own type. modeltypes is the catalog-wide list covering every kind of asset - it
holds "Access Point", "Camera" and "Desktop PC" alongside the machine entries -
so it is a different taxonomy from machinetypes. Only about two thirds of the
names overlap, and mapping one onto the other would mistype the remainder, with
the failure mode being a machine labelled "Desktop PC".

scripts/backfill_vendor_from_model.py writes the derived vendor down for real,
since the display fallback leaves reports that read vendorid still seeing
nothing. It is a dry run unless given --commit, fills only rows where the
asset's vendor is NULL and the model names one, and never overwrites a vendor
somebody chose. It skips a table lacking either column, so it runs against a
server whose network migration has not been applied yet.

Verified against the development database by nulling one machine's vendor inside
a transaction: it was detected as fillable, restored to exactly its original
value, and the rollback left the row untouched.

FLASK_ENV is not forced by the script. The app already reads it from .env, and
overriding it demanded a SECRET_KEY the environment had no reason to supply.
2026-08-05 09:59:39 -04:00

396 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>
<div class="hero-details">
<div class="detail-item" v-if="device.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ device.assetnumber }}</span>
</div>
<div class="detail-item" v-if="device.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span>
</div>
<div class="detail-item" v-if="device.locationname">
<span class="label">Location</span>
<span class="value">{{ device.locationname }}</span>
</div>
<div class="detail-item" v-if="device.businessunitname">
<span class="label">Business Unit</span>
<span class="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>
<!-- The catalog model's own type, kept separate from the device
type: modeltypes spans every kind of asset. -->
<div class="info-row" v-if="device.networkdevice?.modeltypename">
<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'
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
} 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>