Three fixes, all of them a page not doing what its siblings already do. MODELS WERE NOT FILTERED BY VENDOR. PCForm, MachineForm and PrinterForm each narrow the model list once a vendor is chosen; NetworkDeviceForm bound the whole catalogue, so picking Palo Alto still offered every Dell and Zebra model. Same computed as the others, including the same rule that no vendor selected shows everything - an empty dropdown reads as "no models exist" when it means "pick a vendor first". Audited the rest: this was the only gap. The other views holding a modelnumberid have no vendor picker to filter against, and settings ModelsList is where a model's vendor is ASSIGNED, where filtering would be circular. THE HERO RAN THE LABEL INTO THE VALUE - "Asset #FW-OAV..." as one string. The page used detail-item / label / value, which match nothing in the stylesheet, so the two spans got no layout at all. Every other detail page uses hero-detail / hero-detail-label / hero-detail-value, which stacks a small uppercase label above the value. Renamed to those; no CSS added, because the styles already existed and this page simply was not using them. It was the last page using the unstyled names. MACHINES LIST LINKED BY THE WRONG ID on its fallback path. `/machines/:id` keys on machineid, the plugin extension id, and the row click and View button fell back to `item.assetid` - which lands on whichever machine happens to carry that number: a wrong page that looks right, which is worse than a 404. That defect has been fixed twice before in other views (AssetRelationships, then the GE-Enforce reports table) and BackupHistory carries a comment warning about it; this was the fourth copy. The list endpoint always sets item.machine, so the fallback could not actually fire here - it is removed as a latent trap rather than a live bug, and with no machineid the cell now shows plain text rather than a link that misleads.
401 lines
14 KiB
Vue
401 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'
|
|
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>
|