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.
438 lines
16 KiB
Vue
438 lines
16 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Computer Details</h2>
|
|
<div class="header-actions">
|
|
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
|
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
|
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="computer">
|
|
<!-- Hero Section -->
|
|
<div class="hero-card">
|
|
<div class="hero-image" v-if="computer.computer?.imageurl">
|
|
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
|
</div>
|
|
<div class="hero-content">
|
|
<div class="hero-title">
|
|
<h1>{{ computer.assetnumber }}</h1>
|
|
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-lg badge-info">Computer</span>
|
|
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
|
{{ computer.statusname || 'Unknown' }}
|
|
</span>
|
|
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
|
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
|
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
|
</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
|
<span class="hero-detail-label">Type</span>
|
|
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="computer.computer?.osname">
|
|
<span class="hero-detail-label">OS</span>
|
|
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="computer.locationname">
|
|
<span class="hero-detail-label">Location</span>
|
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
|
</div>
|
|
</div>
|
|
</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">{{ computer.assetnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="computer.name">
|
|
<span class="info-label">Name</span>
|
|
<span class="info-value">{{ computer.name }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
|
|
<span class="info-label">Hostname</span>
|
|
<span class="info-value mono">{{ computer.computer.hostname }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="computer.serialnumber">
|
|
<span class="info-label">Serial Number</span>
|
|
<span class="info-value mono">{{ computer.serialnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
|
|
<span class="info-label">Gauge Lab Reference</span>
|
|
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
|
|
<span class="info-label">Maintenance Reference</span>
|
|
<span class="info-value mono">{{ computer.maintenancereference }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hardware Section -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Hardware</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Computer Type</span>
|
|
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Vendor</span>
|
|
<span class="info-value">
|
|
{{ computer.computer?.vendorname || '-' }}
|
|
<!-- Inherited from the catalog model, not stored on this record. -->
|
|
<small v-if="computer.computer?.vendorfrommodel" class="text-muted">(from model)</small>
|
|
</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Model</span>
|
|
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
|
|
</div>
|
|
<!-- The catalog model's own type. Kept separate from this
|
|
asset's type: modeltypes spans every kind of asset. -->
|
|
<div class="info-row" v-if="computer.computer?.modeltypename">
|
|
<span class="info-label">Model type</span>
|
|
<span class="info-value">{{ computer.computer.modeltypename }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Operating System</span>
|
|
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Remote Access -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Remote Access</h3>
|
|
<div class="access-methods">
|
|
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
|
|
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
|
|
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
|
|
</template>
|
|
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status / Check-in -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Status</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Logged In User</span>
|
|
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Last Reported</span>
|
|
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Last Boot</span>
|
|
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="content-column">
|
|
<!-- Network -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Network</h3>
|
|
<div v-if="computer.communications?.length" class="network-list">
|
|
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
|
<div class="network-primary">
|
|
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
|
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
</div>
|
|
<div class="network-secondary" v-if="comm.macaddress">
|
|
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="muted">No network addresses on record</p>
|
|
</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">Location</span>
|
|
<span class="info-value">
|
|
<LocationMapTooltip
|
|
v-if="computer.mapx != null && computer.mapy != null"
|
|
:left="computer.mapx"
|
|
:top="computer.mapy"
|
|
:machineName="computer.assetnumber"
|
|
>
|
|
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
|
</LocationMapTooltip>
|
|
<span v-else>{{ computer.locationname || '-' }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Business Unit</span>
|
|
<span class="info-value">{{ computer.businessunitname || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Installed Applications -->
|
|
<div class="section-card" v-if="installedApps.length > 0">
|
|
<h3 class="section-title">Installed Applications</h3>
|
|
<div class="app-list">
|
|
<router-link
|
|
v-for="app in installedApps"
|
|
:key="app.id"
|
|
:to="`/applications/${app.appid}`"
|
|
class="app-item"
|
|
>
|
|
<div class="app-info">
|
|
<span class="app-name">{{ app.appname }}</span>
|
|
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
|
|
</div>
|
|
<div class="app-desc" v-if="app.appdescription">
|
|
{{ app.appdescription }}
|
|
</div>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Fields -->
|
|
<CustomFieldsSection :assetid="computer.assetid" />
|
|
|
|
<!-- Warranty -->
|
|
<PluginAssetPanels :assetid="computer.assetid" />
|
|
|
|
<!-- All relationships (controls, defaultprinter, ...) -->
|
|
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
|
|
|
<!-- Notes -->
|
|
<div class="section-card" v-if="computer.notes">
|
|
<h3 class="section-title">Notes</h3>
|
|
<p class="notes-text">{{ computer.notes }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Footer -->
|
|
<div class="audit-footer">
|
|
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
|
<span>Modified {{ formatDate(computer.modifieddate) }}<template v-if="computer.modifiedby"> by {{ computer.modifiedby }}</template></span>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { colorStyle } from "@/utils/colorStyle"
|
|
import { useRoute } from 'vue-router'
|
|
import { computersApi, applicationsApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
|
|
const route = useRoute()
|
|
const { isEnabled } = useIdentifierFlags()
|
|
|
|
const loading = ref(true)
|
|
const computer = ref(null)
|
|
const installedApps = ref([])
|
|
|
|
// Warranty fetch + hero badge (shared across asset detail pages).
|
|
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
|
|
|
// relationships render via the shared AssetRelationships card
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await computersApi.get(route.params.id)
|
|
computer.value = response.data.data
|
|
|
|
// Load installed applications
|
|
try {
|
|
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
|
|
installedApps.value = appsResponse.data.data || []
|
|
} catch (appError) {
|
|
}
|
|
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
|
} catch (error) {
|
|
console.error('Error loading computer:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
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') return 'badge-warning'
|
|
if (s === 'retired') return 'badge-danger'
|
|
return 'badge-info'
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* PC-specific styles - shared styles are in global style.css */
|
|
|
|
/* Remote-access protocol links */
|
|
.access-methods {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
.access-methods .muted {
|
|
color: var(--text-light);
|
|
}
|
|
.access-link {
|
|
display: inline-block;
|
|
padding: 3px 12px;
|
|
margin: 0 6px 4px 0;
|
|
border-radius: 14px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
.access-link:hover {
|
|
background: var(--primary-dark);
|
|
text-decoration: none;
|
|
}
|
|
.access-link.disabled {
|
|
background: var(--secondary);
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Installed Applications */
|
|
.app-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.app-item {
|
|
display: block;
|
|
padding: 1rem;
|
|
background: var(--bg);
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.app-item:hover {
|
|
background: var(--border);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.app-info {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.app-name {
|
|
font-weight: 500;
|
|
font-size: 1.125rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.app-version {
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
.app-desc {
|
|
margin-top: 0.375rem;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
/* Network details */
|
|
.network-details {
|
|
margin-top: 0.5rem;
|
|
display: flex;
|
|
gap: 1.25rem;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
/* Network card (IP / MAC list) */
|
|
.network-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.network-item {
|
|
padding: 0.5rem 0.7rem;
|
|
background: var(--bg);
|
|
border-radius: 6px;
|
|
}
|
|
.network-primary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.ip-address {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.comm-type {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
.primary-badge {
|
|
padding: 0.1rem 0.5rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-radius: 10px;
|
|
}
|
|
.mac-address {
|
|
font-size: 0.82rem;
|
|
color: var(--text-light);
|
|
}
|
|
.muted {
|
|
color: var(--text-light);
|
|
margin: 0;
|
|
}
|
|
</style>
|