Migrate frontend to plugin-based asset architecture

- Add equipmentApi and computersApi to replace legacy machinesApi
- Add controller vendor/model fields to Equipment model and forms
- Fix map marker navigation to use plugin-specific IDs (equipmentid,
  computerid, printerid, networkdeviceid) instead of assetid
- Fix search to use unified Asset table with correct plugin IDs
- Remove legacy printer search that used non-existent field names
- Enable optional JWT auth for detail endpoints (public read access)
- Clean up USB plugin models (remove unused checkout model)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-29 16:07:41 -05:00
parent 9c220a4194
commit c3ce69da12
28 changed files with 4123 additions and 3454 deletions

View File

@@ -54,7 +54,7 @@ export const authApi = {
} }
} }
// Machines API // Machines API (legacy - use equipmentApi or computersApi instead)
export const machinesApi = { export const machinesApi = {
list(params = {}) { list(params = {}) {
return api.get('/machines', { params }) return api.get('/machines', { params })
@@ -86,6 +86,89 @@ export const machinesApi = {
} }
} }
// Equipment API (plugin)
export const equipmentApi = {
list(params = {}) {
return api.get('/equipment', { params })
},
get(id) {
return api.get(`/equipment/${id}`)
},
getByAsset(assetId) {
return api.get(`/equipment/by-asset/${assetId}`)
},
create(data) {
return api.post('/equipment', data)
},
update(id, data) {
return api.put(`/equipment/${id}`, data)
},
delete(id) {
return api.delete(`/equipment/${id}`)
},
dashboardSummary() {
return api.get('/equipment/dashboard/summary')
},
// Equipment types
types: {
list(params = {}) {
return api.get('/equipment/types', { params })
},
get(id) {
return api.get(`/equipment/types/${id}`)
},
create(data) {
return api.post('/equipment/types', data)
},
update(id, data) {
return api.put(`/equipment/types/${id}`, data)
}
}
}
// Computers API (plugin)
export const computersApi = {
list(params = {}) {
return api.get('/computers', { params })
},
get(id) {
return api.get(`/computers/${id}`)
},
getByAsset(assetId) {
return api.get(`/computers/by-asset/${assetId}`)
},
getByHostname(hostname) {
return api.get(`/computers/by-hostname/${hostname}`)
},
create(data) {
return api.post('/computers', data)
},
update(id, data) {
return api.put(`/computers/${id}`, data)
},
delete(id) {
return api.delete(`/computers/${id}`)
},
dashboardSummary() {
return api.get('/computers/dashboard/summary')
},
// Computer types
types: {
list(params = {}) {
return api.get('/computers/types', { params })
},
get(id) {
return api.get(`/computers/types/${id}`)
},
create(data) {
return api.post('/computers/types', data)
},
update(id, data) {
return api.put(`/computers/types/${id}`, data)
}
}
}
// Relationship Types API // Relationship Types API
export const relationshipTypesApi = { export const relationshipTypesApi = {
list() { list() {

View File

@@ -6,6 +6,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
const props = defineProps({ const props = defineProps({
left: { type: Number, default: null }, left: { type: Number, default: null },
@@ -23,11 +24,6 @@ const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550 const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]] const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Detect system color scheme
function getTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function initMap() { function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return if (!mapContainer.value || props.left === null || props.top === null) return
@@ -39,8 +35,7 @@ function initMap() {
zoomControl: true zoomControl: true
}) })
const theme = getTheme() const blueprintUrl = currentTheme.value === 'light'
const blueprintUrl = theme === 'light'
? '/static/images/sitemap2025-light.png' ? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png' : '/static/images/sitemap2025-dark.png'

View File

@@ -41,7 +41,8 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
const props = defineProps({ const props = defineProps({
left: { type: Number, default: null }, left: { type: Number, default: null },
@@ -49,22 +50,6 @@ const props = defineProps({
machineName: { type: String, default: '' } machineName: { type: String, default: '' }
}) })
// Auto-detect system theme with reactive updates
const systemTheme = ref(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleThemeChange(e) {
systemTheme.value = e.matches ? 'dark' : 'light'
}
onMounted(() => {
mediaQuery.addEventListener('change', handleThemeChange)
})
onUnmounted(() => {
mediaQuery.removeEventListener('change', handleThemeChange)
})
const visible = ref(false) const visible = ref(false)
const tooltipRef = ref(null) const tooltipRef = ref(null)
const mapPreview = ref(null) const mapPreview = ref(null)
@@ -82,7 +67,9 @@ const hasPosition = computed(() => {
}) })
const blueprintUrl = computed(() => { const blueprintUrl = computed(() => {
return systemTheme.value === 'light' // Force re-evaluation when theme changes by including theme in the computed
const theme = currentTheme.value
return theme === 'light'
? '/static/images/sitemap2025-light.png' ? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png' : '/static/images/sitemap2025-dark.png'
}) })
@@ -196,6 +183,11 @@ watch(visible, (newVal) => {
zoom.value = 1 zoom.value = 1
} }
}) })
// Reset imageLoaded when theme changes to force reload
watch(currentTheme, () => {
imageLoaded.value = false
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="shopfloor-map"> <div class="shopfloor-map">
<div class="map-controls" v-if="!pickerMode"> <!-- Controls for legacy machine mode only (MapView handles filters for asset mode) -->
<div class="map-controls" v-if="!pickerMode && !assetTypeMode">
<div class="filters"> <div class="filters">
<select v-model="filters.machinetype" @change="applyFilters"> <select v-model="filters.machinetype" @change="applyFilters">
<option value="">All Types</option> <option value="">All Types</option>
@@ -43,6 +44,32 @@
</div> </div>
</div> </div>
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
<div class="map-legend" v-if="!pickerMode && assetTypeMode">
<!-- Show subtype legend when a specific type is selected -->
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
<span
v-for="(color, subtypeId) in visibleSubtypes"
:key="subtypeId"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
</span>
</template>
<!-- Show asset type legend when no type is selected -->
<template v-else>
<span
v-for="(color, assetType) in visibleAssetTypes"
:key="assetType"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ assetTypeLabels[assetType] || assetType }}
</span>
</template>
</div>
<div class="picker-controls" v-if="pickerMode"> <div class="picker-controls" v-if="pickerMode">
<span class="picker-message">Click on the map to set location</span> <span class="picker-message">Click on the map to set location</span>
<span v-if="pickedPosition" class="picker-coords"> <span v-if="pickedPosition" class="picker-coords">
@@ -68,7 +95,10 @@ const props = defineProps({
theme: { type: String, default: 'dark' }, theme: { type: String, default: 'dark' },
pickerMode: { type: Boolean, default: false }, pickerMode: { type: Boolean, default: false },
initialPosition: { type: Object, default: null }, // { left, top } initialPosition: { type: Object, default: null }, // { left, top }
assetTypeMode: { type: Boolean, default: false } // When true, use unified asset format assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
subtypeNames: { type: Object, default: () => ({}) } // Map of subtype ID to name
}) })
const emit = defineEmits(['markerClick', 'positionPicked']) const emit = defineEmits(['markerClick', 'positionPicked'])
@@ -92,14 +122,40 @@ const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550 const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]] const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Asset type colors (for unified map mode) // Asset type colors (for unified map mode) - normalized lookup
const assetTypeColors = { const assetTypeColorsMap = {
'equipment': '#F44336', // Red 'equipment': '#F44336', // Red
'computer': '#2196F3', // Blue 'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green 'printer': '#4CAF50', // Green
'network_device': '#FF9800' // Orange 'network device': '#FF9800', // Orange
'network_device': '#FF9800' // Orange (alternate key)
} }
// Get asset type color with case-insensitive lookup
function getAssetTypeColor(assettype) {
if (!assettype) return '#BDBDBD'
const normalized = assettype.toLowerCase()
return assetTypeColorsMap[normalized] || '#BDBDBD'
}
// Asset type labels for display (case-insensitive lookup)
const assetTypeLabelsMap = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
const assetTypeLabels = new Proxy({}, {
get(target, prop) {
if (typeof prop === 'string') {
return assetTypeLabelsMap[prop.toLowerCase()] || prop
}
return prop
}
})
// Type colors - distinct colors for each machine type // Type colors - distinct colors for each machine type
const typeColors = { const typeColors = {
// Machining // Machining
@@ -161,6 +217,44 @@ const visibleTypes = computed(() => {
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid)) return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
}) })
// Get unique visible asset types (for asset mode)
const visibleAssetTypes = computed(() => {
const types = new Set(props.machines.map(m => m.assettype).filter(Boolean))
const result = {}
for (const t of types) {
result[t] = getAssetTypeColor(t)
}
return result
})
// Get subtype ID from asset based on asset type
function getSubtypeId(asset) {
if (!asset.typedata) return null
const typeLower = asset.assettype?.toLowerCase() || ''
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
return null
}
// Get visible subtypes when a type is selected
const visibleSubtypes = computed(() => {
if (!props.selectedAssetType) return {}
const subtypeIds = new Set(
props.machines
.map(m => getSubtypeId(m))
.filter(id => id != null)
)
const result = {}
for (const id of subtypeIds) {
if (props.subtypeColors[id]) {
result[id] = props.subtypeColors[id]
}
}
return result
})
function initMap() { function initMap() {
if (!mapContainer.value) return if (!mapContainer.value) return
@@ -284,9 +378,15 @@ function renderMarkers() {
let color, typeName, displayName, detailRoute let color, typeName, displayName, detailRoute
if (props.assetTypeMode) { if (props.assetTypeMode) {
// Unified asset mode // Unified asset mode - use subtype colors when a type is selected
color = assetTypeColors[item.assettype] || '#BDBDBD' if (props.selectedAssetType) {
typeName = item.assettype || '' const subtypeId = getSubtypeId(item)
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
color = getAssetTypeColor(item.assettype)
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown' displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
detailRoute = getAssetDetailRoute(item) detailRoute = getAssetDetailRoute(item)
} else { } else {
@@ -299,9 +399,9 @@ function renderMarkers() {
const icon = L.divIcon({ const icon = L.divIcon({
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`, html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
iconSize: [24, 24], iconSize: [12, 12],
iconAnchor: [12, 12], iconAnchor: [6, 6],
popupAnchor: [0, -12], popupAnchor: [0, -6],
className: 'machine-marker' className: 'machine-marker'
}) })
@@ -312,13 +412,7 @@ function renderMarkers() {
if (props.assetTypeMode) { if (props.assetTypeMode) {
// Asset mode tooltips // Asset mode tooltips
const assetTypeLabel = { tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
'equipment': 'Equipment',
'computer': 'Computer',
'printer': 'Printer',
'network_device': 'Network Device'
}
tooltipLines.push(`<span style="color: #888;">${assetTypeLabel[item.assettype] || item.assettype}</span>`)
if (item.primaryip) { if (item.primaryip) {
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`) tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
@@ -421,19 +515,30 @@ function renderMarkers() {
// Get detail route for unified asset format // Get detail route for unified asset format
function getAssetDetailRoute(asset) { function getAssetDetailRoute(asset) {
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = { const routeMap = {
'equipment': '/machines', 'equipment': '/machines',
'computer': '/pcs', 'computer': '/pcs',
'printer': '/printers', 'printer': '/printers',
'network_device': '/network' 'network_device': '/network',
'network device': '/network'
} }
const basePath = routeMap[asset.assettype] || '/machines' const basePath = routeMap[assetType] || '/machines'
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) { // Get the plugin-specific ID from typedata
return `/network/${asset.typedata.networkdeviceid}` let id = asset.assetid // fallback
if (asset.typedata) {
if (assetType === 'equipment' && asset.typedata.equipmentid) {
id = asset.typedata.equipmentid
} else if (assetType === 'computer' && asset.typedata.computerid) {
id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
} }
const id = asset.typedata?.machineid || asset.assetid
return `${basePath}/${id}` return `${basePath}/${id}`
} }
@@ -556,6 +661,19 @@ onUnmounted(() => {
color: var(--text); color: var(--text);
} }
.map-legend-bar {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 1rem;
color: var(--text);
padding: 0.5rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.legend-item { .legend-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -645,11 +763,11 @@ onUnmounted(() => {
} }
:deep(.machine-marker-dot) { :deep(.machine-marker-dot) {
width: 24px; width: 12px;
height: 24px; height: 12px;
border-radius: 50%; border-radius: 50%;
border: 3px solid var(--border); border: 2px solid rgba(255,255,255,0.8);
box-shadow: 0 2px 6px rgba(0,0,0,0.5); box-shadow: 0 1px 4px rgba(0,0,0,0.5);
} }
:deep(.picker-marker-dot) { :deep(.picker-marker-dot) {
@@ -676,4 +794,18 @@ onUnmounted(() => {
:deep(.marker-tooltip::before) { :deep(.marker-tooltip::before) {
border-top-color: rgba(0, 0, 0, 0.92); border-top-color: rgba(0, 0, 0, 0.92);
} }
/* Legend bar for asset type mode (no filters - parent handles them) */
.map-legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: var(--text);
}
</style> </style>

View File

@@ -54,10 +54,10 @@
</div> </div>
</div> </div>
<!-- Recent Machines --> <!-- Recent Devices -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Recent Machines</h3> <h3>Recent Devices</h3>
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link> <router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
</div> </div>
@@ -65,26 +65,22 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Machine #</th> <th>Name</th>
<th>Alias</th> <th>Category</th>
<th>Type</th> <th>Type</th>
<th>Status</th> <th>Business Unit</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="machine in recentMachines" :key="machine.machineid"> <tr v-for="machine in recentMachines" :key="machine.machineid">
<td>{{ machine.machinenumber }}</td> <td>{{ machine.machinenumber || machine.hostname || machine.alias || '-' }}</td>
<td>{{ machine.alias || '-' }}</td> <td>{{ machine.category || '-' }}</td>
<td>{{ machine.machinetype }}</td> <td>{{ machine.machinetype || '-' }}</td>
<td> <td>{{ machine.businessunit || '-' }}</td>
<span class="badge" :class="getStatusClass(machine.status)">
{{ machine.status || 'Unknown' }}
</span>
</td>
</tr> </tr>
<tr v-if="recentMachines.length === 0"> <tr v-if="recentMachines.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);"> <td colspan="4" style="text-align: center; color: var(--text-light);">
No machines found No devices found
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -10,19 +10,44 @@
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<!-- Layer Toggles --> <!-- Filter Controls -->
<div class="layer-toggles"> <div class="map-filters">
<label v-for="t in assetTypes" :key="t.assettypeid" class="layer-toggle"> <select v-model="selectedType" @change="onTypeChange">
<input <option value="">All Asset Types</option>
type="checkbox" <option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
v-model="visibleTypes" {{ formatTypeName(t.assettype) }} ({{ getTypeCount(t.assettype) }})
:value="t.assettype" </option>
@change="updateMapLayers" </select>
/>
<span class="layer-icon">{{ getTypeIcon(t.assettype) }}</span> <select v-model="selectedSubtype" @change="updateMapLayers" :disabled="!selectedType || !currentSubtypes.length">
<span>{{ t.assettype }}</span> <option value="">{{ subtypeLabel }}</option>
<span class="layer-count">({{ getTypeCount(t.assettype) }})</span> <option v-for="st in currentSubtypes" :key="st.id" :value="st.id">
</label> {{ st.name }}
</option>
</select>
<select v-model="selectedBusinessUnit" @change="updateMapLayers">
<option value="">All Business Units</option>
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
<select v-model="selectedStatus" @change="updateMapLayers">
<option value="">All Statuses</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
<input
type="text"
v-model="searchQuery"
placeholder="Search assets..."
@input="debouncedSearch"
/>
<span class="result-count">{{ filteredAssets.length }} assets</span>
</div> </div>
<ShopFloorMap <ShopFloorMap
@@ -32,6 +57,9 @@
:statuses="statuses" :statuses="statuses"
:assetTypeMode="true" :assetTypeMode="true"
:theme="currentTheme" :theme="currentTheme"
:selectedAssetType="selectedType"
:subtypeColors="subtypeColorMap"
:subtypeNames="subtypeNameMap"
@markerClick="handleMarkerClick" @markerClick="handleMarkerClick"
/> />
</template> </template>
@@ -53,11 +81,125 @@ const assets = ref([])
const assetTypes = ref([]) const assetTypes = ref([])
const businessunits = ref([]) const businessunits = ref([])
const statuses = ref([]) const statuses = ref([])
const visibleTypes = ref([]) const subtypes = ref({})
// Filter state
const selectedType = ref('')
const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
let searchTimeout = null
// Case-insensitive lookup helper for subtypes
function getSubtypesForType(typeName) {
if (!typeName || !subtypes.value) return []
// Try exact match first
if (subtypes.value[typeName]) return subtypes.value[typeName]
// Try case-insensitive match
const lowerType = typeName.toLowerCase()
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase() === lowerType) return value
}
return []
}
const currentSubtypes = computed(() => {
if (!selectedType.value) return []
return getSubtypesForType(selectedType.value)
})
const subtypeLabel = computed(() => {
if (!selectedType.value) return 'Select type first'
if (!currentSubtypes.value.length) return 'No subtypes'
const labels = {
'equipment': 'All Machine Types',
'computer': 'All Computer Types',
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase()] || 'All Subtypes'
})
// Generate distinct colors for subtypes
const subtypeColorPalette = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// Map subtype IDs to colors
const subtypeColorMap = computed(() => {
const colorMap = {}
const allSubtypes = currentSubtypes.value
allSubtypes.forEach((st, index) => {
colorMap[st.id] = subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
// Map subtype IDs to names
const subtypeNameMap = computed(() => {
const nameMap = {}
currentSubtypes.value.forEach(st => {
nameMap[st.id] = st.name
})
return nameMap
})
const filteredAssets = computed(() => { const filteredAssets = computed(() => {
if (visibleTypes.value.length === 0) return assets.value let result = assets.value
return assets.value.filter(a => visibleTypes.value.includes(a.assettype))
// Filter by asset type (case-insensitive)
if (selectedType.value) {
const selectedLower = selectedType.value.toLowerCase()
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (case-insensitive type check)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = selectedType.value?.toLowerCase() || ''
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
if (typeLower === 'equipment') {
return a.typedata.equipmenttypeid === subtypeId
} else if (typeLower === 'computer') {
return a.typedata.computertypeid === subtypeId
} else if (typeLower === 'network device') {
return a.typedata.networkdevicetypeid === subtypeId
} else if (typeLower === 'printer') {
return a.typedata.printertypeid === subtypeId
}
return false
})
}
// Filter by business unit
if (selectedBusinessUnit.value) {
result = result.filter(a => a.businessunitid === parseInt(selectedBusinessUnit.value))
}
// Filter by status
if (selectedStatus.value) {
result = result.filter(a => a.statusid === parseInt(selectedStatus.value))
}
// Filter by search query
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(a =>
(a.assetnumber && a.assetnumber.toLowerCase().includes(q)) ||
(a.name && a.name.toLowerCase().includes(q)) ||
(a.displayname && a.displayname.toLowerCase().includes(q)) ||
(a.serialnumber && a.serialnumber.toLowerCase().includes(q))
)
}
return result
}) })
onMounted(async () => { onMounted(async () => {
@@ -69,9 +211,7 @@ onMounted(async () => {
assetTypes.value = data.filters?.assettypes || [] assetTypes.value = data.filters?.assettypes || []
businessunits.value = data.filters?.businessunits || [] businessunits.value = data.filters?.businessunits || []
statuses.value = data.filters?.statuses || [] statuses.value = data.filters?.statuses || []
subtypes.value = data.filters?.subtypes || {}
// Default: show all types
visibleTypes.value = assetTypes.value.map(t => t.assettype)
} catch (error) { } catch (error) {
console.error('Failed to load map data:', error) console.error('Failed to load map data:', error)
} finally { } finally {
@@ -79,43 +219,69 @@ onMounted(async () => {
} }
}) })
function getTypeIcon(assettype) { function formatTypeName(assettype) {
const icons = { if (!assettype) return assettype
'equipment': '⚙', const names = {
'computer': '💻', 'equipment': 'Equipment',
'printer': '🖨', 'computer': 'Computers',
'network_device': '🌐' 'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
} }
return icons[assettype] || '📦' return names[assettype.toLowerCase()] || assettype
} }
function getTypeCount(assettype) { function getTypeCount(assettype) {
return assets.value.filter(a => a.assettype === assettype).length if (!assettype) return 0
const lowerType = assettype.toLowerCase()
return assets.value.filter(a => a.assettype && a.assettype.toLowerCase() === lowerType).length
}
function onTypeChange() {
// Reset subtype when type changes
selectedSubtype.value = ''
updateMapLayers()
} }
function updateMapLayers() { function updateMapLayers() {
// Filter is reactive via computed property // Filter is reactive via computed property
} }
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
updateMapLayers()
}, 300)
}
function handleMarkerClick(asset) { function handleMarkerClick(asset) {
// Route based on asset type // Route based on asset type (lowercase keys to match API data)
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = { const routeMap = {
'equipment': '/machines', 'equipment': '/machines',
'computer': '/pcs', 'computer': '/pcs',
'printer': '/printers', 'printer': '/printers',
'network_device': '/network' 'network_device': '/network',
'network device': '/network'
} }
const basePath = routeMap[asset.assettype] || '/machines' const basePath = routeMap[assetType] || '/machines'
// For network devices, use the networkdeviceid from typedata // Get the plugin-specific ID from typedata
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) { let id = asset.assetid // fallback
router.push(`/network/${asset.typedata.networkdeviceid}`) if (asset.typedata) {
} else { if (assetType === 'equipment' && asset.typedata.equipmentid) {
// For machines (equipment, computer, printer), use machineid from typedata id = asset.typedata.equipmentid
const id = asset.typedata?.machineid || asset.assetid } else if (assetType === 'computer' && asset.typedata.computerid) {
router.push(`${basePath}/${id}`) id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
} }
router.push(`${basePath}/${id}`)
} }
</script> </script>
@@ -130,43 +296,45 @@ function handleMarkerClick(asset) {
flex-shrink: 0; flex-shrink: 0;
} }
.layer-toggles { .map-filters {
display: flex; display: flex;
gap: 1rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
}
.layer-toggle {
display: flex;
align-items: center; align-items: center;
gap: 0.5rem; }
cursor: pointer;
.map-filters select,
.map-filters input {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
transition: background 0.2s;
}
.layer-toggle:hover {
background: var(--bg); background: var(--bg);
color: var(--text);
font-size: 0.875rem;
} }
.layer-toggle input[type="checkbox"] { .map-filters select {
width: 1.125rem; min-width: 160px;
height: 1.125rem;
} }
.layer-icon { .map-filters select:disabled {
font-size: 1.25rem; opacity: 0.5;
cursor: not-allowed;
} }
.layer-count { .map-filters input {
min-width: 180px;
}
.result-count {
color: var(--text-light); color: var(--text-light);
font-size: 0.875rem; font-size: 0.875rem;
margin-left: auto;
} }
.map-page :deep(.shopfloor-map) { .map-page :deep(.shopfloor-map) {

View File

@@ -3,7 +3,7 @@
<div class="page-header"> <div class="page-header">
<h2>Equipment Details</h2> <h2>Equipment Details</h2>
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/machines/${machine?.machineid}/edit`" class="btn btn-primary" v-if="machine"> <router-link :to="`/machines/${equipment?.equipment?.equipmentid}/edit`" class="btn btn-primary" v-if="equipment">
Edit Edit
</router-link> </router-link>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link> <router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
@@ -12,41 +12,38 @@
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine"> <template v-else-if="equipment">
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-card"> <div class="hero-card">
<div class="hero-image" v-if="machine.model?.imageurl">
<img :src="machine.model.imageurl" :alt="machine.model?.modelnumber" />
</div>
<div class="hero-content"> <div class="hero-content">
<div class="hero-title"> <div class="hero-title">
<h1>{{ machine.machinenumber }}</h1> <h1>{{ equipment.assetnumber }}</h1>
<span v-if="machine.alias" class="hero-alias">{{ machine.alias }}</span> <span v-if="equipment.name" class="hero-alias">{{ equipment.name }}</span>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg" :class="getCategoryClass(machine.machinetype?.category)"> <span class="badge badge-lg badge-primary">
{{ machine.machinetype?.category || 'Unknown' }} {{ equipment.assettype_name || 'Equipment' }}
</span> </span>
<span class="badge badge-lg" :class="getStatusClass(machine.status?.status)"> <span class="badge badge-lg" :class="getStatusClass(equipment.status_name)">
{{ machine.status?.status || 'Unknown' }} {{ equipment.status_name || 'Unknown' }}
</span> </span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="machine.machinetype?.machinetype"> <div class="hero-detail" v-if="equipment.equipment?.equipmenttype_name">
<span class="hero-detail-label">Type</span> <span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ machine.machinetype.machinetype }}</span> <span class="hero-detail-value">{{ equipment.equipment.equipmenttype_name }}</span>
</div> </div>
<div class="hero-detail" v-if="machine.vendor?.vendor"> <div class="hero-detail" v-if="equipment.equipment?.vendor_name">
<span class="hero-detail-label">Vendor</span> <span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ machine.vendor.vendor }}</span> <span class="hero-detail-value">{{ equipment.equipment.vendor_name }}</span>
</div> </div>
<div class="hero-detail" v-if="machine.model?.modelnumber"> <div class="hero-detail" v-if="equipment.equipment?.model_name">
<span class="hero-detail-label">Model</span> <span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ machine.model.modelnumber }}</span> <span class="hero-detail-value">{{ equipment.equipment.model_name }}</span>
</div> </div>
<div class="hero-detail" v-if="machine.location?.location"> <div class="hero-detail" v-if="equipment.location_name">
<span class="hero-detail-label">Location</span> <span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ machine.location.location }}</span> <span class="hero-detail-value">{{ equipment.location_name }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -61,20 +58,16 @@
<h3 class="section-title">Identity</h3> <h3 class="section-title">Identity</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Machine Number</span> <span class="info-label">Asset Number</span>
<span class="info-value">{{ machine.machinenumber }}</span> <span class="info-value">{{ equipment.assetnumber }}</span>
</div> </div>
<div class="info-row" v-if="machine.alias"> <div class="info-row" v-if="equipment.name">
<span class="info-label">Alias</span> <span class="info-label">Name</span>
<span class="info-value">{{ machine.alias }}</span> <span class="info-value">{{ equipment.name }}</span>
</div> </div>
<div class="info-row" v-if="machine.hostname"> <div class="info-row" v-if="equipment.serialnumber">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ machine.hostname }}</span>
</div>
<div class="info-row" v-if="machine.serialnumber">
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
<span class="info-value mono">{{ machine.serialnumber }}</span> <span class="info-value mono">{{ equipment.serialnumber }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -85,47 +78,73 @@
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Type</span> <span class="info-label">Type</span>
<span class="info-value">{{ machine.machinetype?.machinetype || '-' }}</span> <span class="info-value">{{ equipment.equipment?.equipmenttype_name || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Vendor</span> <span class="info-label">Vendor</span>
<span class="info-value">{{ machine.vendor?.vendor || '-' }}</span> <span class="info-value">{{ equipment.equipment?.vendor_name || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Model</span> <span class="info-label">Model</span>
<span class="info-value">{{ machine.model?.modelnumber || '-' }}</span> <span class="info-value">{{ equipment.equipment?.model_name || '-' }}</span>
</div>
<div class="info-row" v-if="machine.operatingsystem">
<span class="info-label">Operating System</span>
<span class="info-value">{{ machine.operatingsystem.osname }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- PC Information (if PC) --> <!-- Controller Section (for CNC machines) -->
<div class="section-card" v-if="isPc"> <div class="section-card" v-if="equipment.equipment?.controller_vendor_name || equipment.equipment?.controller_model_name">
<h3 class="section-title">PC Status</h3> <h3 class="section-title">Controller</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row" v-if="machine.loggedinuser"> <div class="info-row">
<span class="info-label">Logged In User</span> <span class="info-label">Vendor</span>
<span class="info-value">{{ machine.loggedinuser }}</span> <span class="info-value">{{ equipment.equipment?.controller_vendor_name || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Last Reported</span> <span class="info-label">Model</span>
<span class="info-value">{{ formatDate(machine.lastreporteddate) }}</span> <span class="info-value">{{ equipment.equipment?.controller_model_name || '-' }}</span>
</div> </div>
</div>
</div>
<!-- Equipment Configuration -->
<div class="section-card">
<h3 class="section-title">Configuration</h3>
<div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Last Boot</span> <span class="info-label">Requires Manual Config</span>
<span class="info-value">{{ formatDate(machine.lastboottime) }}</span>
</div>
<div class="info-row">
<span class="info-label">Features</span>
<span class="info-value"> <span class="info-value">
<span class="feature-tag" :class="{ active: machine.isvnc }">VNC</span> <span class="feature-tag" :class="{ active: equipment.equipment?.requiresmanualconfig }">
<span class="feature-tag" :class="{ active: machine.iswinrm }">WinRM</span> {{ equipment.equipment?.requiresmanualconfig ? 'Yes' : 'No' }}
<span class="feature-tag" :class="{ active: machine.isshopfloor }">Shopfloor</span> </span>
</span> </span>
</div> </div>
<div class="info-row">
<span class="info-label">Location Only</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: equipment.equipment?.islocationonly }">
{{ equipment.equipment?.islocationonly ? 'Yes' : 'No' }}
</span>
</span>
</div>
</div>
</div>
<!-- Maintenance Section -->
<div class="section-card" v-if="equipment.equipment?.lastmaintenancedate || equipment.equipment?.nextmaintenancedate">
<h3 class="section-title">Maintenance</h3>
<div class="info-list">
<div class="info-row" v-if="equipment.equipment?.lastmaintenancedate">
<span class="info-label">Last Maintenance</span>
<span class="info-value">{{ formatDate(equipment.equipment.lastmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="equipment.equipment?.nextmaintenancedate">
<span class="info-label">Next Maintenance</span>
<span class="info-value">{{ formatDate(equipment.equipment.nextmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="equipment.equipment?.maintenanceintervaldays">
<span class="info-label">Interval</span>
<span class="info-value">{{ equipment.equipment.maintenanceintervaldays }} days</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -140,31 +159,31 @@
<span class="info-label">Location</span> <span class="info-label">Location</span>
<span class="info-value"> <span class="info-value">
<LocationMapTooltip <LocationMapTooltip
v-if="machine.mapleft != null && machine.maptop != null" v-if="equipment.mapleft != null && equipment.maptop != null"
:left="machine.mapleft" :left="equipment.mapleft"
:top="machine.maptop" :top="equipment.maptop"
:machineName="machine.machinenumber" :machineName="equipment.assetnumber"
> >
<span class="location-link">{{ machine.location?.location || 'On Map' }}</span> <span class="location-link">{{ equipment.location_name || 'On Map' }}</span>
</LocationMapTooltip> </LocationMapTooltip>
<span v-else>{{ machine.location?.location || '-' }}</span> <span v-else>{{ equipment.location_name || '-' }}</span>
</span> </span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Business Unit</span> <span class="info-label">Business Unit</span>
<span class="info-value">{{ machine.businessunit?.businessunit || '-' }}</span> <span class="info-value">{{ equipment.businessunit_name || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Connected PC (for Equipment) --> <!-- Connected PC -->
<div class="section-card" v-if="isEquipment"> <div class="section-card">
<h3 class="section-title">Connected PC</h3> <h3 class="section-title">Connected PC</h3>
<div v-if="!controllingPc" class="empty-message"> <div v-if="!controllingPc" class="empty-message">
No controlling PC assigned No controlling PC assigned
</div> </div>
<div v-else class="connected-device"> <div v-else class="connected-device">
<router-link :to="getRelatedRoute(controllingPc)" class="device-link"> <router-link :to="`/pcs/${controllingPc.plugin_id || controllingPc.assetid}`" class="device-link">
<div class="device-icon"> <div class="device-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect> <rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
@@ -173,66 +192,31 @@
</svg> </svg>
</div> </div>
<div class="device-info"> <div class="device-info">
<span class="device-name">{{ controllingPc.relatedmachinenumber }}</span> <span class="device-name">{{ controllingPc.assetnumber }}</span>
<span class="device-alias" v-if="controllingPc.relatedmachinealias">{{ controllingPc.relatedmachinealias }}</span> <span class="device-alias" v-if="controllingPc.name">{{ controllingPc.name }}</span>
</div> </div>
</router-link> </router-link>
<span class="connection-type">{{ controllingPc.relationshiptype }}</span> <span class="connection-type">{{ controllingPc.relationshipType }}</span>
</div>
</div>
<!-- Controlled Equipment (for PCs) -->
<div class="section-card" v-if="isPc && controlledMachines.length > 0">
<h3 class="section-title">Controlled Equipment</h3>
<div class="equipment-list">
<router-link
v-for="rel in controlledMachines"
:key="rel.relationshipid"
:to="getRelatedRoute(rel)"
class="equipment-item"
>
<div class="equipment-info">
<span class="equipment-name">{{ rel.relatedmachinenumber }}</span>
<span class="equipment-alias" v-if="rel.relatedmachinealias">{{ rel.relatedmachinealias }}</span>
</div>
<span class="connection-tag">{{ rel.relationshiptype }}</span>
</router-link>
</div>
</div>
<!-- Network -->
<div class="section-card" v-if="machine.communications?.length">
<h3 class="section-title">Network</h3>
<div class="network-list">
<div v-for="comm in machine.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>
</div> </div>
<!-- Notes --> <!-- Notes -->
<div class="section-card" v-if="machine.notes"> <div class="section-card" v-if="equipment.notes">
<h3 class="section-title">Notes</h3> <h3 class="section-title">Notes</h3>
<p class="notes-text">{{ machine.notes }}</p> <p class="notes-text">{{ equipment.notes }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Audit Footer --> <!-- Audit Footer -->
<div class="audit-footer"> <div class="audit-footer">
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span> <span>Created {{ formatDate(equipment.createddate) }}<template v-if="equipment.createdby"> by {{ equipment.createdby }}</template></span>
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span> <span>Modified {{ formatDate(equipment.modifieddate) }}<template v-if="equipment.modifiedby"> by {{ equipment.modifiedby }}</template></span>
</div> </div>
</template> </template>
<div v-else class="card"> <div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Machine not found</p> <p style="text-align: center; color: var(--text-light);">Equipment not found</p>
</div> </div>
</div> </div>
</template> </template>
@@ -240,55 +224,63 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { machinesApi } from '../../api' import { equipmentApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
const machine = ref(null) const equipment = ref(null)
const relationships = ref([]) const relationships = ref({ incoming: [], outgoing: [] })
const isPc = computed(() => {
return machine.value?.machinetype?.category === 'PC'
})
const isEquipment = computed(() => {
return machine.value?.machinetype?.category === 'Equipment'
})
const controllingPc = computed(() => { const controllingPc = computed(() => {
return relationships.value.find(r => r.direction === 'controlled_by' && r.relatedcategory === 'PC') // For equipment, find a related computer in any "Controls" relationship
}) // Check both incoming (computer controls this) and outgoing (legacy data may have equipment -> computer)
const controlledMachines = computed(() => { // First check incoming - computer as source controlling this equipment
return relationships.value.filter(r => r.direction === 'controls') for (const rel of relationships.value.incoming || []) {
if (rel.source_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') {
return {
...rel.source_asset,
relationshipType: rel.relationship_type_name
}
}
}
// Also check outgoing - legacy data may have equipment -> computer Controls relationships
for (const rel of relationships.value.outgoing || []) {
if (rel.target_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') {
return {
...rel.target_asset,
relationshipType: rel.relationship_type_name
}
}
}
return null
}) })
onMounted(async () => { onMounted(async () => {
try { try {
const response = await machinesApi.get(route.params.id) const response = await equipmentApi.get(route.params.id)
machine.value = response.data.data equipment.value = response.data.data
const relResponse = await machinesApi.getRelationships(route.params.id) // Load relationships using asset ID
relationships.value = relResponse.data.data || [] if (equipment.value?.assetid) {
try {
const relResponse = await assetsApi.getRelationships(equipment.value.assetid)
relationships.value = relResponse.data.data || { incoming: [], outgoing: [] }
} catch (e) {
console.log('Relationships not available')
}
}
} catch (error) { } catch (error) {
console.error('Error loading machine:', error) console.error('Error loading equipment:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
function getCategoryClass(category) {
if (!category) return 'badge-info'
const c = category.toLowerCase()
if (c === 'equipment') return 'badge-primary'
if (c === 'pc') return 'badge-info'
if (c === 'network') return 'badge-warning'
if (c === 'printer') return 'badge-secondary'
return 'badge-info'
}
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()
@@ -302,17 +294,31 @@ function formatDate(dateStr) {
if (!dateStr) return '-' if (!dateStr) return '-'
return new Date(dateStr).toLocaleString() return new Date(dateStr).toLocaleString()
} }
function getRelatedRoute(rel) {
const category = rel.relatedcategory?.toLowerCase() || ''
const routeMap = {
'equipment': '/machines',
'pc': '/pcs',
'printer': '/printers'
}
const basePath = routeMap[category] || '/machines'
return `${basePath}/${rel.relatedmachineid}`
}
</script> </script>
<!-- Uses global detail page styles from style.css --> <style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.feature-tag {
display: inline-block;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.active {
background: #e3f2fd;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.feature-tag.active {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -7,13 +7,15 @@
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveMachine"> <form v-else @submit.prevent="saveEquipment">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="machinenumber">Machine Number *</label> <label for="assetnumber">Asset Number *</label>
<input <input
id="machinenumber" id="assetnumber"
v-model="form.machinenumber" v-model="form.assetnumber"
type="text" type="text"
class="form-control" class="form-control"
required required
@@ -21,10 +23,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="alias">Alias</label> <label for="name">Name / Alias</label>
<input <input
id="alias" id="name"
v-model="form.alias" v-model="form.name"
type="text" type="text"
class="form-control" class="form-control"
/> />
@@ -32,16 +34,6 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="hostname">Hostname</label>
<input
id="hostname"
v-model="form.hostname"
type="text"
class="form-control"
/>
</div>
<div class="form-group"> <div class="form-group">
<label for="serialnumber">Serial Number</label> <label for="serialnumber">Serial Number</label>
<input <input
@@ -51,27 +43,6 @@
class="form-control" class="form-control"
/> />
</div> </div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Equipment Type *</label>
<select
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
required
>
<option value="">Select type...</option>
<option
v-for="mt in machineTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
>
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="statusid">Status</label> <label for="statusid">Status</label>
@@ -92,7 +63,27 @@
</div> </div>
</div> </div>
<!-- Equipment Hardware Section -->
<h3 class="form-section-title">Equipment Hardware</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="equipmenttypeid">Equipment Type</label>
<select
id="equipmenttypeid"
v-model="form.equipmenttypeid"
class="form-control"
>
<option value="">Select type...</option>
<option
v-for="et in equipmentTypes"
:key="et.equipmenttypeid"
:value="et.equipmenttypeid"
>
{{ et.equipmenttype }}
</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="vendorid">Vendor</label> <label for="vendorid">Vendor</label>
<select <select
@@ -110,7 +101,9 @@
</option> </option>
</select> </select>
</div> </div>
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="modelnumberid">Model</label> <label for="modelnumberid">Model</label>
<select <select
@@ -127,10 +120,56 @@
{{ m.modelnumber }} {{ m.modelnumber }}
</option> </option>
</select> </select>
<small class="form-help">Selecting a model will auto-set the equipment type</small> <small class="form-help" v-if="form.vendorid">Filtered by selected vendor</small>
</div>
<div class="form-group"></div>
</div>
<!-- Controller Section (for CNC machines) -->
<h3 class="form-section-title">Controller</h3>
<div class="form-row">
<div class="form-group">
<label for="controller_vendorid">Controller Vendor</label>
<select
id="controller_vendorid"
v-model="form.controller_vendorid"
class="form-control"
>
<option value="">Select vendor...</option>
<option
v-for="v in vendors"
:key="v.vendorid"
:value="v.vendorid"
>
{{ v.vendor }}
</option>
</select>
<small class="form-help">e.g., Fanuc, Siemens, Allen-Bradley</small>
</div>
<div class="form-group">
<label for="controller_modelid">Controller Model</label>
<select
id="controller_modelid"
v-model="form.controller_modelid"
class="form-control"
>
<option value="">Select model...</option>
<option
v-for="m in filteredControllerModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small class="form-help" v-if="form.controller_vendorid">Filtered by controller vendor</small>
</div> </div>
</div> </div>
<!-- Location Section -->
<h3 class="form-section-title">Location & Organization</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="locationid">Location</label> <label for="locationid">Location</label>
@@ -145,7 +184,7 @@
:key="l.locationid" :key="l.locationid"
:value="l.locationid" :value="l.locationid"
> >
{{ l.location }} {{ l.locationname }}
</option> </option>
</select> </select>
</div> </div>
@@ -169,56 +208,6 @@
</div> </div>
</div> </div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
></textarea>
</div>
<!-- Controlling PC Selection -->
<div class="form-row">
<div class="form-group">
<label for="controllingpc">Controlling PC</label>
<select
id="controllingpc"
v-model="controllingPcId"
class="form-control"
>
<option :value="null">None (standalone)</option>
<option
v-for="pc in pcs"
:key="pc.machineid"
:value="pc.machineid"
>
{{ pc.machinenumber }}{{ pc.alias ? ` (${pc.alias})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this equipment</small>
</div>
<div class="form-group" v-if="controllingPcId">
<label for="connectiontype">Connection Type</label>
<select
id="connectiontype"
v-model="relationshipTypeId"
class="form-control"
>
<option
v-for="rt in relationshipTypes"
:key="rt.relationshiptypeid"
:value="rt.relationshiptypeid"
>
{{ rt.relationshiptype }}
</option>
</select>
<small class="form-help">How the PC connects to this equipment</small>
</div>
</div>
<!-- Map Location Picker --> <!-- Map Location Picker -->
<div class="form-group"> <div class="form-group">
<label>Map Location</label> <label>Map Location</label>
@@ -249,6 +238,79 @@
</template> </template>
</Modal> </Modal>
<!-- Configuration Section -->
<h3 class="form-section-title">Configuration</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.requiresmanualconfig" />
Requires Manual Config
</label>
<small class="form-help">Multi-PC machine needs manual configuration</small>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.islocationonly" />
Location Only
</label>
<small class="form-help">Virtual location marker (not actual equipment)</small>
</div>
</div>
<!-- Controlling PC Selection -->
<h3 class="form-section-title">Connected PC</h3>
<div class="form-row">
<div class="form-group">
<label for="controllingpc">Controlling PC</label>
<select
id="controllingpc"
v-model="controllingPcId"
class="form-control"
>
<option :value="null">None (standalone)</option>
<option
v-for="pc in pcs"
:key="pc.assetid"
:value="pc.assetid"
>
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this equipment</small>
</div>
<div class="form-group" v-if="controllingPcId">
<label for="connectiontype">Connection Type</label>
<select
id="connectiontype"
v-model="relationshipTypeId"
class="form-control"
>
<option
v-for="rt in relationshipTypes"
:key="rt.relationshiptypeid"
:value="rt.relationshiptypeid"
>
{{ rt.relationshiptype }}
</option>
</select>
<small class="form-help">How the PC connects to this equipment</small>
</div>
</div>
<!-- Notes Section -->
<h3 class="form-section-title">Notes</h3>
<div class="form-group">
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
placeholder="Additional notes..."
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;"> <div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -265,7 +327,7 @@
<script setup> <script setup>
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api' import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme' import { currentTheme } from '../../stores/theme'
@@ -282,22 +344,25 @@ const showMapPicker = ref(false)
const tempMapPosition = ref(null) const tempMapPosition = ref(null)
const form = ref({ const form = ref({
machinenumber: '', assetnumber: '',
alias: '', name: '',
hostname: '',
serialnumber: '', serialnumber: '',
machinetypeid: '', statusid: 1,
modelnumberid: '', equipmenttypeid: '',
statusid: '',
vendorid: '', vendorid: '',
modelnumberid: '',
controller_vendorid: '',
controller_modelid: '',
locationid: '', locationid: '',
businessunitid: '', businessunitid: '',
requiresmanualconfig: false,
islocationonly: false,
notes: '', notes: '',
mapleft: null, mapleft: null,
maptop: null maptop: null
}) })
const machineTypes = ref([]) const equipmentTypes = ref([])
const statuses = ref([]) const statuses = ref([])
const vendors = ref([]) const vendors = ref([])
const locations = ref([]) const locations = ref([])
@@ -308,55 +373,73 @@ const relationshipTypes = ref([])
const controllingPcId = ref(null) const controllingPcId = ref(null)
const relationshipTypeId = ref(null) const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null) const existingRelationshipId = ref(null)
const currentEquipment = ref(null)
// Filter models by selected vendor // Filter models by selected equipment vendor
const filteredModels = computed(() => { const filteredModels = computed(() => {
return models.value.filter(m => { if (!form.value.vendorid) return models.value
// Filter by vendor if selected return models.value.filter(m => m.vendorid === form.value.vendorid)
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// Only show models for Equipment category types
const modelType = machineTypes.value.find(t => t.machinetypeid === m.machinetypeid)
if (modelType && modelType.category !== 'Equipment') {
return false
}
return true
})
}) })
// When model changes, auto-set the machine type // Filter models by selected controller vendor
watch(() => form.value.modelnumberid, (newModelId) => { const filteredControllerModels = computed(() => {
if (newModelId) { if (!form.value.controller_vendorid) return models.value
const selectedModel = models.value.find(m => m.modelnumberid === newModelId) return models.value.filter(m => m.vendorid === form.value.controller_vendorid)
if (selectedModel && selectedModel.machinetypeid) { })
form.value.machinetypeid = selectedModel.machinetypeid
// Clear model selection when vendor changes
watch(() => form.value.vendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.modelnumberid = ''
}
}
})
// Clear controller model when controller vendor changes
watch(() => form.value.controller_vendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.controller_modelid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.controller_modelid = ''
} }
} }
}) })
onMounted(async () => { onMounted(async () => {
try { try {
// Load reference data // Load reference data in parallel
const [mtRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([ const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([
machinetypesApi.list({ category: 'Equipment' }), equipmentApi.types.list(),
statusesApi.list(), assetsApi.statuses.list(),
vendorsApi.list(), vendorsApi.list({ per_page: 500 }),
locationsApi.list(), locationsApi.list({ per_page: 500 }),
modelsApi.list(), modelsApi.list({ per_page: 1000 }),
businessunitsApi.list(), businessunitsApi.list({ per_page: 500 }),
machinesApi.list({ category: 'PC', perpage: 500 }), computersApi.list({ per_page: 500 }),
relationshipTypesApi.list() assetsApi.types.list() // Used for relationship types, will fix below
]) ])
machineTypes.value = mtRes.data.data || [] equipmentTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || [] statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || [] vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || [] locations.value = locRes.data.data || []
models.value = modelsRes.data.data || [] models.value = modelsRes.data.data || []
businessunits.value = buRes.data.data || [] businessunits.value = buRes.data.data || []
pcs.value = pcsRes.data.data || [] pcs.value = pcsRes.data.data || []
relationshipTypes.value = relTypesRes.data.data || []
// Load relationship types separately
try {
const relRes = await fetch('/api/assets/relationshiptypes')
if (relRes.ok) {
const relData = await relRes.json()
relationshipTypes.value = relData.data || []
}
} catch (e) {
// Fallback - use hardcoded Controls type
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]
}
// Set default relationship type to "Controls" if available // Set default relationship type to "Controls" if available
const controlsType = relationshipTypes.value.find(t => t.relationshiptype === 'Controls') const controlsType = relationshipTypes.value.find(t => t.relationshiptype === 'Controls')
@@ -364,34 +447,49 @@ onMounted(async () => {
relationshipTypeId.value = controlsType.relationshiptypeid relationshipTypeId.value = controlsType.relationshiptypeid
} }
// Load machine if editing // Load equipment if editing
if (isEdit.value) { if (isEdit.value) {
const response = await machinesApi.get(route.params.id) const response = await equipmentApi.get(route.params.id)
const machine = response.data.data const data = response.data.data
currentEquipment.value = data
form.value = { form.value = {
machinenumber: machine.machinenumber || '', assetnumber: data.assetnumber || '',
alias: machine.alias || '', name: data.name || '',
hostname: machine.hostname || '', serialnumber: data.serialnumber || '',
serialnumber: machine.serialnumber || '', statusid: data.statusid || 1,
machinetypeid: machine.machinetype?.machinetypeid || '', equipmenttypeid: data.equipment?.equipmenttypeid || '',
modelnumberid: machine.model?.modelnumberid || '', vendorid: data.equipment?.vendorid || '',
statusid: machine.status?.statusid || '', modelnumberid: data.equipment?.modelnumberid || '',
vendorid: machine.vendor?.vendorid || '', controller_vendorid: data.equipment?.controller_vendorid || '',
locationid: machine.location?.locationid || '', controller_modelid: data.equipment?.controller_modelid || '',
businessunitid: machine.businessunit?.businessunitid || '', locationid: data.locationid || '',
notes: machine.notes || '', businessunitid: data.businessunitid || '',
mapleft: machine.mapleft ?? null, requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
maptop: machine.maptop ?? null islocationonly: data.equipment?.islocationonly || false,
notes: data.notes || '',
mapleft: data.mapleft ?? null,
maptop: data.maptop ?? null
} }
// Load existing relationship (controlling PC) // Load existing relationships to find controlling PC
const relResponse = await machinesApi.getRelationships(route.params.id) if (data.assetid) {
const relationships = relResponse.data.data || [] try {
const pcRelationship = relationships.find(r => r.direction === 'controlled_by' && r.relatedcategory === 'PC') const relResponse = await assetsApi.getRelationships(data.assetid)
if (pcRelationship) { const relationships = relResponse.data.data || { incoming: [], outgoing: [] }
controllingPcId.value = pcRelationship.relatedmachineid
relationshipTypeId.value = pcRelationship.relationshiptypeid // Check incoming relationships for a controlling PC
existingRelationshipId.value = pcRelationship.relationshipid for (const rel of relationships.incoming || []) {
if (rel.source_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') {
controllingPcId.value = rel.source_asset.assetid
relationshipTypeId.value = rel.relationshiptypeid
existingRelationshipId.value = rel.assetrelationshipid
break
}
}
} catch (e) {
console.log('Could not load relationships')
}
} }
} }
} catch (err) { } catch (err) {
@@ -420,45 +518,56 @@ function clearMapPosition() {
tempMapPosition.value = null tempMapPosition.value = null
} }
async function saveMachine() { async function saveEquipment() {
error.value = '' error.value = ''
saving.value = true saving.value = true
try { try {
const data = { const data = {
...form.value, assetnumber: form.value.assetnumber,
machinetypeid: form.value.machinetypeid || null, name: form.value.name || null,
modelnumberid: form.value.modelnumberid || null, serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || null, statusid: form.value.statusid || 1,
equipmenttypeid: form.value.equipmenttypeid || null,
vendorid: form.value.vendorid || null, vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
controller_vendorid: form.value.controller_vendorid || null,
controller_modelid: form.value.controller_modelid || null,
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null, businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly,
notes: form.value.notes || null,
mapleft: form.value.mapleft, mapleft: form.value.mapleft,
maptop: form.value.maptop maptop: form.value.maptop
} }
let machineId = route.params.id let savedEquipment
let assetId
if (isEdit.value) { if (isEdit.value) {
await machinesApi.update(route.params.id, data) const response = await equipmentApi.update(route.params.id, data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
} else { } else {
const response = await machinesApi.create(data) const response = await equipmentApi.create(data)
machineId = response.data.data.machineid savedEquipment = response.data.data
assetId = savedEquipment.assetid
} }
// Handle relationship (controlling PC) // Handle relationship (controlling PC)
await saveRelationship(machineId) await saveRelationship(assetId)
router.push('/machines') router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
} catch (err) { } catch (err) {
console.error('Error saving machine:', err) console.error('Error saving equipment:', err)
error.value = err.response?.data?.message || 'Failed to save machine' error.value = err.response?.data?.message || 'Failed to save equipment'
} finally { } finally {
saving.value = false saving.value = false
} }
} }
async function saveRelationship(machineId) { async function saveRelationship(assetId) {
// If no PC selected and no existing relationship, nothing to do // If no PC selected and no existing relationship, nothing to do
if (!controllingPcId.value && !existingRelationshipId.value) { if (!controllingPcId.value && !existingRelationshipId.value) {
return return
@@ -466,7 +575,7 @@ async function saveRelationship(machineId) {
// If clearing the relationship // If clearing the relationship
if (!controllingPcId.value && existingRelationshipId.value) { if (!controllingPcId.value && existingRelationshipId.value) {
await machinesApi.deleteRelationship(existingRelationshipId.value) await assetsApi.deleteRelationship(existingRelationshipId.value)
return return
} }
@@ -474,20 +583,50 @@ async function saveRelationship(machineId) {
if (controllingPcId.value) { if (controllingPcId.value) {
// If there's an existing relationship, delete it first // If there's an existing relationship, delete it first
if (existingRelationshipId.value) { if (existingRelationshipId.value) {
await machinesApi.deleteRelationship(existingRelationshipId.value) await assetsApi.deleteRelationship(existingRelationshipId.value)
} }
// Create new relationship // Create new relationship (PC controls Equipment, so PC is source, Equipment is target)
await machinesApi.createRelationship(machineId, { await assetsApi.createRelationship({
relatedmachineid: controllingPcId.value, source_assetid: controllingPcId.value,
relationshiptypeid: relationshipTypeId.value, target_assetid: assetId,
direction: 'controlled_by' relationshiptypeid: relationshipTypeId.value
}) })
} }
} }
</script> </script>
<style scoped> <style scoped>
.form-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.form-section-title:first-of-type {
margin-top: 0;
}
.checkbox-group {
display: flex;
flex-direction: column;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
}
.map-location-control { .map-location-control {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -499,7 +638,7 @@ async function saveRelationship(machineId) {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #e3f2fd; background: var(--bg);
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
} }

View File

@@ -24,9 +24,9 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Machine #</th> <th>Asset #</th>
<th>Alias</th> <th>Name</th>
<th>Hostname</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>
<th>Status</th> <th>Status</th>
<th>Location</th> <th>Location</th>
@@ -34,27 +34,27 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="machine in machines" :key="machine.machineid"> <tr v-for="item in equipment" :key="item.assetid">
<td>{{ machine.machinenumber }}</td> <td>{{ item.assetnumber }}</td>
<td>{{ machine.alias || '-' }}</td> <td>{{ item.name || '-' }}</td>
<td>{{ machine.hostname || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ machine.machinetype }}</td> <td>{{ item.equipment?.equipmenttype_name || '-' }}</td>
<td> <td>
<span class="badge" :class="getStatusClass(machine.status)"> <span class="badge" :class="getStatusClass(item.status_name)">
{{ machine.status || 'Unknown' }} {{ item.status_name || 'Unknown' }}
</span> </span>
</td> </td>
<td>{{ machine.location || '-' }}</td> <td>{{ item.location_name || '-' }}</td>
<td class="actions"> <td class="actions">
<router-link <router-link
:to="`/machines/${machine.machineid}`" :to="`/machines/${item.equipment?.equipmentid || item.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="machines.length === 0"> <tr v-if="equipment.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);"> <td colspan="7" style="text-align: center; color: var(--text-light);">
No equipment found No equipment found
</td> </td>
@@ -82,9 +82,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { machinesApi } from '../../api' import { equipmentApi } from '../../api'
const machines = ref([]) const equipment = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
@@ -93,21 +93,20 @@ const totalPages = ref(1)
let searchTimeout = null let searchTimeout = null
onMounted(() => { onMounted(() => {
loadMachines() loadEquipment()
}) })
async function loadMachines() { async function loadEquipment() {
loading.value = true loading.value = true
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20, per_page: 20
category: 'Equipment'
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await machinesApi.list(params) const response = await equipmentApi.list(params)
machines.value = response.data.data || [] equipment.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading equipment:', error) console.error('Error loading equipment:', error)
@@ -120,13 +119,13 @@ function debouncedSearch() {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
page.value = 1 page.value = 1
loadMachines() loadEquipment()
}, 300) }, 300)
} }
function goToPage(p) { function goToPage(p) {
page.value = p page.value = p
loadMachines() loadEquipment()
} }
function getStatusClass(status) { function getStatusClass(status) {
@@ -138,3 +137,9 @@ function getStatusClass(status) {
return 'badge-info' return 'badge-info'
} }
</script> </script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="detail-page"> <div class="detail-page">
<div class="page-header"> <div class="page-header">
<h2>PC Details</h2> <h2>Computer Details</h2>
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</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> <router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
@@ -10,39 +10,32 @@
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="pc"> <template v-else-if="computer">
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-card"> <div class="hero-card">
<div class="hero-image" v-if="pc.model?.imageurl">
<img :src="pc.model.imageurl" :alt="pc.model?.modelnumber" />
</div>
<div class="hero-content"> <div class="hero-content">
<div class="hero-title"> <div class="hero-title">
<h1>{{ pc.machinenumber }}</h1> <h1>{{ computer.assetnumber }}</h1>
<span v-if="pc.alias" class="hero-alias">{{ pc.alias }}</span> <span v-if="computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg badge-info">PC</span> <span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :class="getStatusClass(pc.status?.status)"> <span class="badge badge-lg" :class="getStatusClass(computer.status_name)">
{{ pc.status?.status || 'Unknown' }} {{ computer.status_name || 'Unknown' }}
</span> </span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="pc.pctype"> <div class="hero-detail" v-if="computer.computer?.computertype_name">
<span class="hero-detail-label">PC Type</span> <span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ pc.pctype.pctype || pc.pctype }}</span> <span class="hero-detail-value">{{ computer.computer.computertype_name }}</span>
</div> </div>
<div class="hero-detail" v-if="pc.vendor?.vendor"> <div class="hero-detail" v-if="computer.computer?.os_name">
<span class="hero-detail-label">Vendor</span> <span class="hero-detail-label">OS</span>
<span class="hero-detail-value">{{ pc.vendor.vendor }}</span> <span class="hero-detail-value">{{ computer.computer.os_name }}</span>
</div> </div>
<div class="hero-detail" v-if="pc.model?.modelnumber"> <div class="hero-detail" v-if="computer.location_name">
<span class="hero-detail-label">Model</span> <span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ pc.model.modelnumber }}</span> <span class="hero-detail-value">{{ computer.location_name }}</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>
</div> </div>
</div> </div>
@@ -57,20 +50,20 @@
<h3 class="section-title">Identity</h3> <h3 class="section-title">Identity</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Machine Number</span> <span class="info-label">Asset Number</span>
<span class="info-value">{{ pc.machinenumber }}</span> <span class="info-value">{{ computer.assetnumber }}</span>
</div> </div>
<div class="info-row" v-if="pc.alias"> <div class="info-row" v-if="computer.name">
<span class="info-label">Alias</span> <span class="info-label">Name</span>
<span class="info-value">{{ pc.alias }}</span> <span class="info-value">{{ computer.name }}</span>
</div> </div>
<div class="info-row" v-if="pc.hostname"> <div class="info-row" v-if="computer.computer?.hostname">
<span class="info-label">Hostname</span> <span class="info-label">Hostname</span>
<span class="info-value mono">{{ pc.hostname }}</span> <span class="info-value mono">{{ computer.computer.hostname }}</span>
</div> </div>
<div class="info-row" v-if="pc.serialnumber"> <div class="info-row" v-if="computer.serialnumber">
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
<span class="info-value mono">{{ pc.serialnumber }}</span> <span class="info-value mono">{{ computer.serialnumber }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -79,21 +72,13 @@
<div class="section-card"> <div class="section-card">
<h3 class="section-title">Hardware</h3> <h3 class="section-title">Hardware</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row" v-if="pc.pctype"> <div class="info-row">
<span class="info-label">PC Type</span> <span class="info-label">Computer Type</span>
<span class="info-value">{{ pc.pctype.pctype || pc.pctype }}</span> <span class="info-value">{{ computer.computer?.computertype_name || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ pc.vendor?.vendor || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ pc.model?.modelnumber || '-' }}</span>
</div>
<div class="info-row" v-if="pc.operatingsystem">
<span class="info-label">Operating System</span> <span class="info-label">Operating System</span>
<span class="info-value">{{ pc.operatingsystem.osname }}</span> <span class="info-value">{{ computer.computer?.os_name || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -102,18 +87,26 @@
<div class="section-card"> <div class="section-card">
<h3 class="section-title">Status</h3> <h3 class="section-title">Status</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row" v-if="pc.loggedinuser"> <div class="info-row" v-if="computer.computer?.loggedinuser">
<span class="info-label">Logged In User</span> <span class="info-label">Logged In User</span>
<span class="info-value">{{ pc.loggedinuser }}</span> <span class="info-value">{{ computer.computer.loggedinuser }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Features</span> <span class="info-label">Features</span>
<span class="info-value"> <span class="info-value">
<span class="feature-tag" :class="{ active: pc.isvnc }">VNC</span> <span class="feature-tag" :class="{ active: computer.computer?.isvnc }">VNC</span>
<span class="feature-tag" :class="{ active: pc.iswinrm }">WinRM</span> <span class="feature-tag" :class="{ active: computer.computer?.iswinrm }">WinRM</span>
<span class="feature-tag" :class="{ active: pc.isshopfloor }">Shopfloor</span> <span class="feature-tag" :class="{ active: computer.computer?.isshopfloor }">Shopfloor</span>
</span> </span>
</div> </div>
<div class="info-row" v-if="computer.computer?.lastreporteddate">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ formatDate(computer.computer.lastreporteddate) }}</span>
</div>
<div class="info-row" v-if="computer.computer?.lastboottime">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ formatDate(computer.computer.lastboottime) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -128,36 +121,40 @@
<span class="info-label">Location</span> <span class="info-label">Location</span>
<span class="info-value"> <span class="info-value">
<LocationMapTooltip <LocationMapTooltip
v-if="pc.mapleft != null && pc.maptop != null" v-if="computer.mapleft != null && computer.maptop != null"
:left="pc.mapleft" :left="computer.mapleft"
:top="pc.maptop" :top="computer.maptop"
:machineName="pc.machinenumber" :machineName="computer.assetnumber"
> >
<span class="location-link">{{ pc.location?.location || 'On Map' }}</span> <span class="location-link">{{ computer.location_name || 'On Map' }}</span>
</LocationMapTooltip> </LocationMapTooltip>
<span v-else>{{ pc.location?.location || '-' }}</span> <span v-else>{{ computer.location_name || '-' }}</span>
</span> </span>
</div> </div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ computer.businessunit_name || '-' }}</span>
</div>
</div> </div>
</div> </div>
<!-- Controlled Equipment --> <!-- Controlled Equipment -->
<div class="section-card" v-if="controlledMachines.length > 0"> <div class="section-card" v-if="controlledEquipment.length > 0">
<h3 class="section-title">Controlled Equipment</h3> <h3 class="section-title">Controlled Equipment</h3>
<div class="equipment-list"> <div class="equipment-list">
<router-link <router-link
v-for="rel in controlledMachines" v-for="item in controlledEquipment"
:key="rel.relationshipid" :key="item.relationshipid"
:to="getRelatedRoute(rel)" :to="`/machines/${item.plugin_id || item.assetid}`"
class="equipment-item" class="equipment-item"
> >
<div class="equipment-info"> <div class="equipment-info">
<span class="equipment-name">{{ rel.relatedmachinenumber }}</span> <span class="equipment-name">{{ item.assetnumber }}</span>
<span class="equipment-alias" v-if="rel.relatedmachinealias">{{ rel.relatedmachinealias }}</span> <span class="equipment-alias" v-if="item.name">{{ item.name }}</span>
</div> </div>
<div class="equipment-meta"> <div class="equipment-meta">
<span class="category-tag">{{ rel.relatedcategory }}</span> <span class="category-tag">{{ item.assettype_name }}</span>
<span class="connection-tag">{{ rel.relationshiptype }}</span> <span class="connection-tag">{{ item.relationshipType }}</span>
</div> </div>
</router-link> </router-link>
</div> </div>
@@ -184,43 +181,23 @@
</div> </div>
</div> </div>
<!-- Network -->
<div class="section-card" v-if="pc.communications?.length">
<h3 class="section-title">Network Interfaces</h3>
<div class="network-list">
<div v-for="comm in pc.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 class="network-details" v-if="comm.subnetmask || comm.defaultgateway">
<span v-if="comm.subnetmask">Subnet: {{ comm.subnetmask }}</span>
<span v-if="comm.defaultgateway">Gateway: {{ comm.defaultgateway }}</span>
</div>
</div>
</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="section-card" v-if="pc.notes"> <div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3> <h3 class="section-title">Notes</h3>
<p class="notes-text">{{ pc.notes }}</p> <p class="notes-text">{{ computer.notes }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Audit Footer --> <!-- Audit Footer -->
<div class="audit-footer"> <div class="audit-footer">
<span>Created {{ formatDate(pc.createddate) }}<template v-if="pc.createdby"> by {{ pc.createdby }}</template></span> <span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
<span>Modified {{ formatDate(pc.modifieddate) }}<template v-if="pc.modifiedby"> by {{ pc.modifiedby }}</template></span> <span>Modified {{ formatDate(computer.modifieddate) }}<template v-if="computer.modifiedby"> by {{ computer.modifiedby }}</template></span>
</div> </div>
</template> </template>
<div v-else class="card"> <div v-else class="card">
<p style="text-align: center; color: var(--text-light);">PC not found</p> <p style="text-align: center; color: var(--text-light);">Computer not found</p>
</div> </div>
</div> </div>
</template> </template>
@@ -228,60 +205,74 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { machinesApi, applicationsApi } from '../../api' import { computersApi, applicationsApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
const pc = ref(null) const computer = ref(null)
const relationships = ref([]) const relationships = ref({ incoming: [], outgoing: [] })
const installedApps = ref([]) const installedApps = ref([])
const ipAddress = computed(() => { const controlledEquipment = computed(() => {
if (!pc.value?.communications) return null // For computers, find related equipment in any "Controls" relationship
const primaryComm = pc.value.communications.find(c => c.isprimary) || pc.value.communications[0] const items = []
return primaryComm?.ipaddress || primaryComm?.address || null
})
const controlledMachines = computed(() => { // Check outgoing - computer controls equipment
return relationships.value.filter(r => r.direction === 'controls') for (const rel of relationships.value.outgoing || []) {
if (rel.target_asset?.assettype_name === 'equipment' && rel.relationship_type_name === 'Controls') {
items.push({
...rel.target_asset,
relationshipid: rel.relationshipid,
relationshipType: rel.relationship_type_name
})
}
}
// Also check incoming - legacy data may have equipment -> computer Controls relationships
for (const rel of relationships.value.incoming || []) {
if (rel.source_asset?.assettype_name === 'equipment' && rel.relationship_type_name === 'Controls') {
items.push({
...rel.source_asset,
relationshipid: rel.relationshipid,
relationshipType: rel.relationship_type_name
})
}
}
return items
}) })
onMounted(async () => { onMounted(async () => {
try { try {
const response = await machinesApi.get(route.params.id) const response = await computersApi.get(route.params.id)
pc.value = response.data.data computer.value = response.data.data
const relResponse = await machinesApi.getRelationships(route.params.id) // Load relationships using asset ID
relationships.value = relResponse.data.data || [] if (computer.value?.assetid) {
try {
const relResponse = await assetsApi.getRelationships(computer.value.assetid)
relationships.value = relResponse.data.data || { incoming: [], outgoing: [] }
} catch (e) {
console.log('Relationships not available')
}
}
// Load installed applications // Load installed applications
try { try {
const appsResponse = await applicationsApi.getMachineApps(route.params.id) const appsResponse = await applicationsApi.getMachineApps(route.params.id)
installedApps.value = appsResponse.data.data || [] installedApps.value = appsResponse.data.data || []
} catch (appError) { } catch (appError) {
// Silently handle if no apps table yet
console.log('No installed apps data:', appError.message) console.log('No installed apps data:', appError.message)
} }
} catch (error) { } catch (error) {
console.error('Error loading PC:', error) console.error('Error loading computer:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
function getRelatedRoute(rel) {
const category = rel.relatedcategory?.toLowerCase() || ''
const routeMap = {
'equipment': '/machines',
'pc': '/pcs',
'printer': '/printers'
}
const basePath = routeMap[category] || '/machines'
return `${basePath}/${rel.relatedmachineid}`
}
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
const s = status.toLowerCase() const s = status.toLowerCase()

View File

@@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>PCs</h2> <h2>Computers</h2>
<router-link to="/pcs/new" class="btn btn-primary">Add PC</router-link> <router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -11,7 +11,7 @@
v-model="search" v-model="search"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search PCs..." placeholder="Search computers..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
</div> </div>
@@ -24,41 +24,43 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Machine #</th> <th>Asset #</th>
<th>Hostname</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>PC Type</th> <th>Type</th>
<th>Features</th> <th>Features</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="pc in pcs" :key="pc.machineid"> <tr v-for="item in computers" :key="item.assetid">
<td>{{ pc.machinenumber }}</td> <td>{{ item.assetnumber }}</td>
<td class="mono">{{ pc.serialnumber || '-' }}</td> <td>{{ item.computer?.hostname || '-' }}</td>
<td>{{ pc.pctype || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertype_name || '-' }}</td>
<td class="features"> <td class="features">
<span v-if="pc.isvnc" class="feature-tag active">VNC</span> <span v-if="item.computer?.isvnc" class="feature-tag active">VNC</span>
<span v-if="pc.iswinrm" class="feature-tag active">WinRM</span> <span v-if="item.computer?.iswinrm" class="feature-tag active">WinRM</span>
<span v-if="!pc.isvnc && !pc.iswinrm">-</span> <span v-if="!item.computer?.isvnc && !item.computer?.iswinrm">-</span>
</td> </td>
<td> <td>
<span class="badge" :class="getStatusClass(pc.status)"> <span class="badge" :class="getStatusClass(item.status_name)">
{{ pc.status || 'Unknown' }} {{ item.status_name || 'Unknown' }}
</span> </span>
</td> </td>
<td class="actions"> <td class="actions">
<router-link <router-link
:to="`/pcs/${pc.machineid}`" :to="`/pcs/${item.computer?.computerid || item.assetid}`"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
> >
View View
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="pcs.length === 0"> <tr v-if="computers.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);"> <td colspan="7" style="text-align: center; color: var(--text-light);">
No PCs found No computers found
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -83,9 +85,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { machinesApi } from '../../api' import { computersApi } from '../../api'
const pcs = ref([]) const computers = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
@@ -94,24 +96,23 @@ const totalPages = ref(1)
let searchTimeout = null let searchTimeout = null
onMounted(() => { onMounted(() => {
loadPCs() loadComputers()
}) })
async function loadPCs() { async function loadComputers() {
loading.value = true loading.value = true
try { try {
const params = { const params = {
page: page.value, page: page.value,
perpage: 20, per_page: 20
category: 'PC'
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
const response = await machinesApi.list(params) const response = await computersApi.list(params)
pcs.value = response.data.data || [] computers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading PCs:', error) console.error('Error loading computers:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -121,13 +122,13 @@ function debouncedSearch() {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
page.value = 1 page.value = 1
loadPCs() loadComputers()
}, 300) }, 300)
} }
function goToPage(p) { function goToPage(p) {
page.value = p page.value = p
loadPCs() loadComputers()
} }
function getStatusClass(status) { function getStatusClass(status) {

View File

@@ -13,11 +13,11 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:5000', target: 'http://localhost:5001',
changeOrigin: true changeOrigin: true
}, },
'/static': { '/static': {
target: 'http://localhost:5000', target: 'http://localhost:5001',
changeOrigin: true changeOrigin: true
} }
} }

View File

@@ -23,7 +23,7 @@ computers_bp = Blueprint('computers', __name__)
# ============================================================================= # =============================================================================
@computers_bp.route('/types', methods=['GET']) @computers_bp.route('/types', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_computer_types(): def list_computer_types():
"""List all computer types.""" """List all computer types."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -45,7 +45,7 @@ def list_computer_types():
@computers_bp.route('/types/<int:type_id>', methods=['GET']) @computers_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_computer_type(type_id: int): def get_computer_type(type_id: int):
"""Get a single computer type.""" """Get a single computer type."""
t = ComputerType.query.get(type_id) t = ComputerType.query.get(type_id)
@@ -126,7 +126,7 @@ def update_computer_type(type_id: int):
# ============================================================================= # =============================================================================
@computers_bp.route('', methods=['GET']) @computers_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_computers(): def list_computers():
""" """
List all computers with filtering and pagination. List all computers with filtering and pagination.
@@ -211,7 +211,7 @@ def list_computers():
@computers_bp.route('/<int:computer_id>', methods=['GET']) @computers_bp.route('/<int:computer_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_computer(computer_id: int): def get_computer(computer_id: int):
"""Get a single computer with full details.""" """Get a single computer with full details."""
comp = Computer.query.get(computer_id) comp = Computer.query.get(computer_id)
@@ -230,7 +230,7 @@ def get_computer(computer_id: int):
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET']) @computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_computer_by_asset(asset_id: int): def get_computer_by_asset(asset_id: int):
"""Get computer data by asset ID.""" """Get computer data by asset ID."""
comp = Computer.query.filter_by(assetid=asset_id).first() comp = Computer.query.filter_by(assetid=asset_id).first()
@@ -249,7 +249,7 @@ def get_computer_by_asset(asset_id: int):
@computers_bp.route('/by-hostname/<hostname>', methods=['GET']) @computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_computer_by_hostname(hostname: str): def get_computer_by_hostname(hostname: str):
"""Get computer by hostname.""" """Get computer by hostname."""
comp = Computer.query.filter_by(hostname=hostname).first() comp = Computer.query.filter_by(hostname=hostname).first()
@@ -441,7 +441,7 @@ def delete_computer(computer_id: int):
# ============================================================================= # =============================================================================
@computers_bp.route('/<int:computer_id>/apps', methods=['GET']) @computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_installed_apps(computer_id: int): def get_installed_apps(computer_id: int):
"""Get all installed applications for a computer.""" """Get all installed applications for a computer."""
comp = Computer.query.get(computer_id) comp = Computer.query.get(computer_id)
@@ -547,7 +547,7 @@ def remove_installed_app(computer_id: int, app_id: int):
# ============================================================================= # =============================================================================
@computers_bp.route('/<int:computer_id>/report', methods=['POST']) @computers_bp.route('/<int:computer_id>/report', methods=['POST'])
@jwt_required(optional=True) @jwt_required()
def report_status(computer_id: int): def report_status(computer_id: int):
""" """
Report computer status (for agent-based reporting). Report computer status (for agent-based reporting).
@@ -585,7 +585,7 @@ def report_status(computer_id: int):
# ============================================================================= # =============================================================================
@computers_bp.route('/dashboard/summary', methods=['GET']) @computers_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def dashboard_summary(): def dashboard_summary():
"""Get computer dashboard summary data.""" """Get computer dashboard summary data."""
# Total active computers # Total active computers

View File

@@ -23,7 +23,7 @@ equipment_bp = Blueprint('equipment', __name__)
# ============================================================================= # =============================================================================
@equipment_bp.route('/types', methods=['GET']) @equipment_bp.route('/types', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_equipment_types(): def list_equipment_types():
"""List all equipment types.""" """List all equipment types."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -45,7 +45,7 @@ def list_equipment_types():
@equipment_bp.route('/types/<int:type_id>', methods=['GET']) @equipment_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_equipment_type(type_id: int): def get_equipment_type(type_id: int):
"""Get a single equipment type.""" """Get a single equipment type."""
t = EquipmentType.query.get(type_id) t = EquipmentType.query.get(type_id)
@@ -126,7 +126,7 @@ def update_equipment_type(type_id: int):
# ============================================================================= # =============================================================================
@equipment_bp.route('', methods=['GET']) @equipment_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_equipment(): def list_equipment():
""" """
List all equipment with filtering and pagination. List all equipment with filtering and pagination.
@@ -201,7 +201,7 @@ def list_equipment():
@equipment_bp.route('/<int:equipment_id>', methods=['GET']) @equipment_bp.route('/<int:equipment_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_equipment(equipment_id: int): def get_equipment(equipment_id: int):
"""Get a single equipment item with full details.""" """Get a single equipment item with full details."""
equip = Equipment.query.get(equipment_id) equip = Equipment.query.get(equipment_id)
@@ -220,7 +220,7 @@ def get_equipment(equipment_id: int):
@equipment_bp.route('/by-asset/<int:asset_id>', methods=['GET']) @equipment_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_equipment_by_asset(asset_id: int): def get_equipment_by_asset(asset_id: int):
"""Get equipment data by asset ID.""" """Get equipment data by asset ID."""
equip = Equipment.query.filter_by(assetid=asset_id).first() equip = Equipment.query.filter_by(assetid=asset_id).first()
@@ -305,7 +305,9 @@ def create_equipment():
islocationonly=data.get('islocationonly', False), islocationonly=data.get('islocationonly', False),
lastmaintenancedate=data.get('lastmaintenancedate'), lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'), nextmaintenancedate=data.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays') maintenanceintervaldays=data.get('maintenanceintervaldays'),
controller_vendorid=data.get('controller_vendorid'),
controller_modelid=data.get('controller_modelid')
) )
db.session.add(equip) db.session.add(equip)
@@ -355,7 +357,8 @@ def update_equipment(equipment_id: int):
# Update equipment fields # Update equipment fields
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid', equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly', 'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays'] 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controller_vendorid', 'controller_modelid']
for key in equipment_fields: for key in equipment_fields:
if key in data: if key in data:
setattr(equip, key, data[key]) setattr(equip, key, data[key])
@@ -393,7 +396,7 @@ def delete_equipment(equipment_id: int):
# ============================================================================= # =============================================================================
@equipment_bp.route('/dashboard/summary', methods=['GET']) @equipment_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def dashboard_summary(): def dashboard_summary():
"""Get equipment dashboard summary data.""" """Get equipment dashboard summary data."""
# Total active equipment count # Total active equipment count

View File

@@ -77,14 +77,30 @@ class Equipment(BaseModel):
nextmaintenancedate = db.Column(db.DateTime, nullable=True) nextmaintenancedate = db.Column(db.DateTime, nullable=True)
maintenanceintervaldays = db.Column(db.Integer, nullable=True) maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines)
controller_vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True,
comment='Controller vendor (e.g., FANUC)'
)
controller_modelid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True,
comment='Controller model (e.g., 31B)'
)
# Relationships # Relationships
asset = db.relationship( asset = db.relationship(
'Asset', 'Asset',
backref=db.backref('equipment', uselist=False, lazy='joined') backref=db.backref('equipment', uselist=False, lazy='joined')
) )
equipmenttype = db.relationship('EquipmentType', backref='equipment') equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', backref='equipment_items') vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
model = db.relationship('Model', backref='equipment_items') model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
controller_vendor = db.relationship('Vendor', foreign_keys=[controller_vendorid], backref='equipment_controllers')
controller_model = db.relationship('Model', foreign_keys=[controller_modelid], backref='equipment_controller_models')
__table_args__ = ( __table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'), db.Index('idx_equipment_type', 'equipmenttypeid'),
@@ -106,4 +122,10 @@ class Equipment(BaseModel):
if self.model: if self.model:
result['model_name'] = self.model.modelnumber result['model_name'] = self.model.modelnumber
# Add controller info
if self.controller_vendor:
result['controller_vendor_name'] = self.controller_vendor.vendor
if self.controller_model:
result['controller_model_name'] = self.controller_model.modelnumber
return result return result

View File

@@ -23,7 +23,7 @@ network_bp = Blueprint('network', __name__)
# ============================================================================= # =============================================================================
@network_bp.route('/types', methods=['GET']) @network_bp.route('/types', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_network_device_types(): def list_network_device_types():
"""List all network device types.""" """List all network device types."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -45,7 +45,7 @@ def list_network_device_types():
@network_bp.route('/types/<int:type_id>', methods=['GET']) @network_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_network_device_type(type_id: int): def get_network_device_type(type_id: int):
"""Get a single network device type.""" """Get a single network device type."""
t = NetworkDeviceType.query.get(type_id) t = NetworkDeviceType.query.get(type_id)
@@ -126,7 +126,7 @@ def update_network_device_type(type_id: int):
# ============================================================================= # =============================================================================
@network_bp.route('', methods=['GET']) @network_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_network_devices(): def list_network_devices():
""" """
List all network devices with filtering and pagination. List all network devices with filtering and pagination.
@@ -214,7 +214,7 @@ def list_network_devices():
@network_bp.route('/<int:device_id>', methods=['GET']) @network_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_network_device(device_id: int): def get_network_device(device_id: int):
"""Get a single network device with full details.""" """Get a single network device with full details."""
netdev = NetworkDevice.query.get(device_id) netdev = NetworkDevice.query.get(device_id)
@@ -233,7 +233,7 @@ def get_network_device(device_id: int):
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET']) @network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_network_device_by_asset(asset_id: int): def get_network_device_by_asset(asset_id: int):
"""Get network device data by asset ID.""" """Get network device data by asset ID."""
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first() netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
@@ -252,7 +252,7 @@ def get_network_device_by_asset(asset_id: int):
@network_bp.route('/by-hostname/<hostname>', methods=['GET']) @network_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_network_device_by_hostname(hostname: str): def get_network_device_by_hostname(hostname: str):
"""Get network device by hostname.""" """Get network device by hostname."""
netdev = NetworkDevice.query.filter_by(hostname=hostname).first() netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
@@ -443,7 +443,7 @@ def delete_network_device(device_id: int):
# ============================================================================= # =============================================================================
@network_bp.route('/dashboard/summary', methods=['GET']) @network_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def dashboard_summary(): def dashboard_summary():
"""Get network device dashboard summary data.""" """Get network device dashboard summary data."""
# Total active network devices # Total active network devices
@@ -491,7 +491,7 @@ def dashboard_summary():
# ============================================================================= # =============================================================================
@network_bp.route('/vlans', methods=['GET']) @network_bp.route('/vlans', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_vlans(): def list_vlans():
"""List all VLANs with filtering and pagination.""" """List all VLANs with filtering and pagination."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -524,7 +524,7 @@ def list_vlans():
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET']) @network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_vlan(vlan_id: int): def get_vlan(vlan_id: int):
"""Get a single VLAN with its subnets.""" """Get a single VLAN with its subnets."""
vlan = VLAN.query.get(vlan_id) vlan = VLAN.query.get(vlan_id)
@@ -644,7 +644,7 @@ def delete_vlan(vlan_id: int):
# ============================================================================= # =============================================================================
@network_bp.route('/subnets', methods=['GET']) @network_bp.route('/subnets', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_subnets(): def list_subnets():
"""List all subnets with filtering and pagination.""" """List all subnets with filtering and pagination."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
@@ -685,7 +685,7 @@ def list_subnets():
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET']) @network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_subnet(subnet_id: int): def get_subnet(subnet_id: int):
"""Get a single subnet.""" """Get a single subnet."""
subnet = Subnet.query.get(subnet_id) subnet = Subnet.query.get(subnet_id)

View File

@@ -94,7 +94,7 @@ def create_printer_type():
# ============================================================================= # =============================================================================
@printers_asset_bp.route('', methods=['GET']) @printers_asset_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_printers(): def list_printers():
""" """
List all printers with filtering and pagination. List all printers with filtering and pagination.
@@ -186,7 +186,7 @@ def list_printers():
@printers_asset_bp.route('/<int:printer_id>', methods=['GET']) @printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_printer(printer_id: int): def get_printer(printer_id: int):
"""Get a single printer with full details.""" """Get a single printer with full details."""
printer = Printer.query.get(printer_id) printer = Printer.query.get(printer_id)
@@ -210,7 +210,7 @@ def get_printer(printer_id: int):
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET']) @printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_printer_by_asset(asset_id: int): def get_printer_by_asset(asset_id: int):
"""Get printer data by asset ID.""" """Get printer data by asset ID."""
printer = Printer.query.filter_by(assetid=asset_id).first() printer = Printer.query.filter_by(assetid=asset_id).first()
@@ -437,7 +437,7 @@ def get_printer_supplies(printer_id: int):
# ============================================================================= # =============================================================================
@printers_asset_bp.route('/dashboard/summary', methods=['GET']) @printers_asset_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def dashboard_summary(): def dashboard_summary():
"""Get printer dashboard summary data.""" """Get printer dashboard summary data."""
# Total active printers # Total active printers
@@ -467,6 +467,10 @@ def dashboard_summary():
return success_response({ return success_response({
'total': total, 'total': total,
'totalprinters': total, # For dashboard compatibility
'online': total, # Placeholder - would need monitoring integration
'lowsupplies': 0, # Placeholder - would need Zabbix integration
'criticalsupplies': 0, # Placeholder - would need Zabbix integration
'by_type': [{'type': t, 'count': c} for t, c in by_type], 'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor], 'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
}) })

View File

@@ -1,11 +1,10 @@
"""USB plugin API endpoints.""" """USB plugin API endpoints."""
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Machine, MachineType, Vendor, Model
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
error_response, error_response,
@@ -14,94 +13,143 @@ from shopdb.utils.responses import (
) )
from shopdb.utils.pagination import get_pagination_params, paginate_query from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import USBCheckout from ..models import USBDevice, USBDeviceType, USBCheckout
usb_bp = Blueprint('usb', __name__) usb_bp = Blueprint('usb', __name__)
def get_usb_machinetype_id(): # =============================================================================
"""Get the USB Device machine type ID dynamically.""" # USB Device Types
usb_type = MachineType.query.filter( # =============================================================================
MachineType.machinetype.ilike('%usb%')
).first()
return usb_type.machinetypeid if usb_type else None
@usb_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_device_types():
"""List all USB device types."""
types = USBDeviceType.query.filter_by(isactive=True).order_by(USBDeviceType.typename).all()
return success_response([{
'usbdevicetypeid': t.usbdevicetypeid,
'typename': t.typename,
'description': t.description,
'icon': t.icon
} for t in types])
@usb_bp.route('/types', methods=['POST'])
@jwt_required()
def create_device_type():
"""Create a new USB device type."""
data = request.get_json() or {}
if not data.get('typename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')
if USBDeviceType.query.filter_by(typename=data['typename']).first():
return error_response(ErrorCodes.CONFLICT, 'Type name already exists', http_code=409)
device_type = USBDeviceType(
typename=data['typename'],
description=data.get('description'),
icon=data.get('icon', 'usb')
)
db.session.add(device_type)
db.session.commit()
return success_response({
'usbdevicetypeid': device_type.usbdevicetypeid,
'typename': device_type.typename
}, message='Device type created', http_code=201)
# =============================================================================
# USB Devices
# =============================================================================
@usb_bp.route('', methods=['GET']) @usb_bp.route('', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_usb_devices(): def list_usb_devices():
""" """
List all USB devices with checkout status. List all USB devices with checkout status.
Query parameters: Query parameters:
- page, per_page: Pagination - page, per_page: Pagination
- search: Search by serial number or alias - search: Search by serial number, label, or asset number
- available: Filter to only available (not checked out) devices - available: Filter to only available (not checked out) devices
- typeid: Filter by device type ID
""" """
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
usb_type_id = get_usb_machinetype_id() query = USBDevice.query.filter_by(isactive=True)
if not usb_type_id:
return success_response([]) # No USB type found
# Get USB devices from machines table # Filter by type
query = db.session.query(Machine).filter( if type_id := request.args.get('typeid'):
Machine.machinetypeid == usb_type_id, query = query.filter_by(usbdevicetypeid=int(type_id))
Machine.isactive == True
) # Filter by checkout status
if request.args.get('available', '').lower() == 'true':
query = query.filter_by(ischeckedout=False)
elif request.args.get('checkedout', '').lower() == 'true':
query = query.filter_by(ischeckedout=True)
# Search filter # Search filter
if search := request.args.get('search'): if search := request.args.get('search'):
query = query.filter( query = query.filter(
db.or_( db.or_(
Machine.serialnumber.ilike(f'%{search}%'), USBDevice.serialnumber.ilike(f'%{search}%'),
Machine.alias.ilike(f'%{search}%'), USBDevice.label.ilike(f'%{search}%'),
Machine.machinenumber.ilike(f'%{search}%') USBDevice.assetnumber.ilike(f'%{search}%'),
USBDevice.manufacturer.ilike(f'%{search}%')
) )
) )
query = query.order_by(Machine.alias) query = query.order_by(USBDevice.label, USBDevice.serialnumber)
items, total = paginate_query(query, page, per_page) items, total = paginate_query(query, page, per_page)
data = [device.to_dict() for device in items]
# Build response with checkout status
data = []
for device in items:
# Check if currently checked out
active_checkout = USBCheckout.query.filter_by(
machineid=device.machineid,
checkin_time=None
).first()
item = {
'machineid': device.machineid,
'machinenumber': device.machinenumber,
'alias': device.alias,
'serialnumber': device.serialnumber,
'notes': device.notes,
'vendor_name': device.vendor.vendorname if device.vendor else None,
'model_name': device.model.modelnumber if device.model else None,
'is_checked_out': active_checkout is not None,
'current_checkout': active_checkout.to_dict() if active_checkout else None
}
data.append(item)
# Filter by availability if requested
if request.args.get('available', '').lower() == 'true':
data = [d for d in data if not d['is_checked_out']]
total = len(data)
return paginated_response(data, page, per_page, total) return paginated_response(data, page, per_page, total)
@usb_bp.route('/<int:device_id>', methods=['GET']) @usb_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
def create_usb_device():
"""Create a new USB device."""
data = request.get_json() or {}
if not data.get('serialnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'serialnumber is required')
if USBDevice.query.filter_by(serialnumber=data['serialnumber']).first():
return error_response(ErrorCodes.CONFLICT, 'Serial number already exists', http_code=409)
device = USBDevice(
serialnumber=data['serialnumber'],
label=data.get('label'),
assetnumber=data.get('assetnumber'),
usbdevicetypeid=data.get('usbdevicetypeid'),
capacitygb=data.get('capacitygb'),
vendorid=data.get('vendorid'),
productid=data.get('productid'),
manufacturer=data.get('manufacturer'),
productname=data.get('productname'),
storagelocation=data.get('storagelocation'),
pin=data.get('pin'),
notes=data.get('notes'),
ischeckedout=False
)
db.session.add(device)
db.session.commit()
return success_response(device.to_dict(), message='Device created', http_code=201)
@usb_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required(optional=True)
def get_usb_device(device_id: int): def get_usb_device(device_id: int):
"""Get a single USB device with checkout history.""" """Get a single USB device with checkout history."""
device = Machine.query.filter_by( device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device: if not device:
return error_response( return error_response(
@@ -110,38 +158,81 @@ def get_usb_device(device_id: int):
http_code=404 http_code=404
) )
# Get checkout history # Get recent checkout history
checkouts = USBCheckout.query.filter_by( checkouts = USBCheckout.query.filter_by(
machineid=device_id usbdeviceid=device_id
).order_by(USBCheckout.checkout_time.desc()).limit(50).all() ).order_by(USBCheckout.checkout_time.desc()).limit(20).all()
# Check current checkout result = device.to_dict()
active_checkout = next((c for c in checkouts if c.checkin_time is None), None) result['checkout_history'] = [c.to_dict() for c in checkouts]
result = {
'machineid': device.machineid,
'machinenumber': device.machinenumber,
'alias': device.alias,
'serialnumber': device.serialnumber,
'notes': device.notes,
'vendor_name': device.vendor.vendorname if device.vendor else None,
'model_name': device.model.modelnumber if device.model else None,
'is_checked_out': active_checkout is not None,
'current_checkout': active_checkout.to_dict() if active_checkout else None,
'checkout_history': [c.to_dict() for c in checkouts]
}
return success_response(result) return success_response(result)
@usb_bp.route('/<int:device_id>', methods=['PUT'])
@jwt_required()
def update_usb_device(device_id: int):
"""Update a USB device."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
data = request.get_json() or {}
# Update allowed fields
for field in ['label', 'assetnumber', 'usbdevicetypeid', 'capacitygb',
'vendorid', 'productid', 'manufacturer', 'productname',
'storagelocation', 'pin', 'notes']:
if field in data:
setattr(device, field, data[field])
device.modifieddate = datetime.utcnow()
db.session.commit()
return success_response(device.to_dict(), message='Device updated')
@usb_bp.route('/<int:device_id>', methods=['DELETE'])
@jwt_required()
def delete_usb_device(device_id: int):
"""Soft delete a USB device."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
if device.ischeckedout:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Cannot delete a device that is currently checked out',
http_code=400
)
device.isactive = False
device.modifieddate = datetime.utcnow()
db.session.commit()
return success_response(None, message='Device deleted')
# =============================================================================
# Checkout/Checkin Operations
# =============================================================================
@usb_bp.route('/<int:device_id>/checkout', methods=['POST']) @usb_bp.route('/<int:device_id>/checkout', methods=['POST'])
@jwt_required() @jwt_required()
def checkout_device(device_id: int): def checkout_device(device_id: int):
"""Check out a USB device.""" """Check out a USB device."""
device = Machine.query.filter_by( device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device: if not device:
return error_response( return error_response(
@@ -150,16 +241,10 @@ def checkout_device(device_id: int):
http_code=404 http_code=404
) )
# Check if already checked out if device.ischeckedout:
active_checkout = USBCheckout.query.filter_by(
machineid=device_id,
checkin_time=None
).first()
if active_checkout:
return error_response( return error_response(
ErrorCodes.CONFLICT, ErrorCodes.CONFLICT,
f'Device is already checked out by {active_checkout.sso}', f'Device is already checked out to {device.currentusername or device.currentuserid}',
http_code=409 http_code=409
) )
@@ -168,14 +253,24 @@ def checkout_device(device_id: int):
if not data.get('sso'): if not data.get('sso'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'sso is required')
# Create checkout record
checkout = USBCheckout( checkout = USBCheckout(
machineid=device_id, usbdeviceid=device_id,
machineid=0, # Legacy field, set to 0 for new checkouts
sso=data['sso'], sso=data['sso'],
checkout_name=data.get('name'), checkout_name=data.get('checkout_name'),
checkout_reason=data.get('reason'), checkout_time=datetime.utcnow(),
checkout_time=datetime.utcnow() checkout_reason=data.get('checkout_reason'),
was_wiped=False
) )
# Update device status
device.ischeckedout = True
device.currentuserid = data['sso']
device.currentusername = data.get('checkout_name')
device.currentcheckoutdate = datetime.utcnow()
device.modifieddate = datetime.utcnow()
db.session.add(checkout) db.session.add(checkout)
db.session.commit() db.session.commit()
@@ -186,10 +281,7 @@ def checkout_device(device_id: int):
@jwt_required() @jwt_required()
def checkin_device(device_id: int): def checkin_device(device_id: int):
"""Check in a USB device.""" """Check in a USB device."""
device = Machine.query.filter_by( device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device: if not device:
return error_response( return error_response(
@@ -198,38 +290,53 @@ def checkin_device(device_id: int):
http_code=404 http_code=404
) )
# Find active checkout if not device.ischeckedout:
active_checkout = USBCheckout.query.filter_by(
machineid=device_id,
checkin_time=None
).first()
if not active_checkout:
return error_response( return error_response(
ErrorCodes.VALIDATION_ERROR, ErrorCodes.VALIDATION_ERROR,
'Device is not currently checked out', 'Device is not currently checked out',
http_code=400 http_code=400
) )
# Find active checkout
active_checkout = USBCheckout.query.filter_by(
usbdeviceid=device_id,
checkin_time=None
).first()
data = request.get_json() or {} data = request.get_json() or {}
active_checkout.checkin_time = datetime.utcnow() if active_checkout:
active_checkout.was_wiped = data.get('was_wiped', False) active_checkout.checkin_time = datetime.utcnow()
active_checkout.checkin_notes = data.get('notes') active_checkout.checkin_notes = data.get('checkin_notes', active_checkout.checkin_notes)
active_checkout.was_wiped = data.get('was_wiped', False)
# Update device status
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
device.modifieddate = datetime.utcnow()
db.session.commit() db.session.commit()
return success_response(active_checkout.to_dict(), message='Device checked in') return success_response(
active_checkout.to_dict() if active_checkout else None,
message='Device checked in'
)
# =============================================================================
# Checkout History
# =============================================================================
@usb_bp.route('/<int:device_id>/history', methods=['GET']) @usb_bp.route('/<int:device_id>/history', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_checkout_history(device_id: int): def get_device_history(device_id: int):
"""Get checkout history for a USB device.""" """Get checkout history for a USB device."""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
query = USBCheckout.query.filter_by( query = USBCheckout.query.filter_by(
machineid=device_id usbdeviceid=device_id
).order_by(USBCheckout.checkout_time.desc()) ).order_by(USBCheckout.checkout_time.desc())
items, total = paginate_query(query, page, per_page) items, total = paginate_query(query, page, per_page)
@@ -239,14 +346,18 @@ def get_checkout_history(device_id: int):
@usb_bp.route('/checkouts', methods=['GET']) @usb_bp.route('/checkouts', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def list_all_checkouts(): def list_all_checkouts():
"""List all checkouts (active and historical).""" """
List all checkouts (active and historical).
Query parameters:
- active: Filter to only active (not returned) checkouts
- sso: Filter by user SSO
"""
page, per_page = get_pagination_params(request) page, per_page = get_pagination_params(request)
query = db.session.query(USBCheckout).join( query = USBCheckout.query
Machine, USBCheckout.machineid == Machine.machineid
)
# Filter by active only # Filter by active only
if request.args.get('active', '').lower() == 'true': if request.args.get('active', '').lower() == 'true':
@@ -259,17 +370,17 @@ def list_all_checkouts():
query = query.order_by(USBCheckout.checkout_time.desc()) query = query.order_by(USBCheckout.checkout_time.desc())
items, total = paginate_query(query, page, per_page) items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
# Include device info
data = []
for checkout in items:
device = Machine.query.get(checkout.machineid)
item = checkout.to_dict()
item['device'] = {
'machineid': device.machineid,
'alias': device.alias,
'serialnumber': device.serialnumber
} if device else None
data.append(item)
return paginated_response(data, page, per_page, total) return paginated_response(data, page, per_page, total)
@usb_bp.route('/checkouts/active', methods=['GET'])
@jwt_required(optional=True)
def list_active_checkouts():
"""List all currently active checkouts."""
checkouts = USBCheckout.query.filter(
USBCheckout.checkin_time == None
).order_by(USBCheckout.checkout_time.desc()).all()
return success_response([c.to_dict() for c in checkouts])

View File

@@ -1,5 +1,5 @@
"""USB plugin models.""" """USB plugin models."""
from .usb_checkout import USBCheckout from .usb_device import USBDevice, USBDeviceType, USBCheckout
__all__ = ['USBCheckout'] __all__ = ['USBDevice', 'USBDeviceType', 'USBCheckout']

View File

@@ -1,38 +0,0 @@
"""USB Checkout model."""
from shopdb.extensions import db
from datetime import datetime
class USBCheckout(db.Model):
"""
USB device checkout tracking.
References machines table (USB devices have machinetypeid=44).
"""
__tablename__ = 'usbcheckouts'
checkoutid = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False, index=True)
sso = db.Column(db.String(20), nullable=False, index=True)
checkout_name = db.Column(db.String(100))
checkout_reason = db.Column(db.Text)
checkout_time = db.Column(db.DateTime, default=datetime.utcnow, index=True)
checkin_time = db.Column(db.DateTime, index=True)
was_wiped = db.Column(db.Boolean, default=False)
checkin_notes = db.Column(db.Text)
def to_dict(self):
"""Convert to dictionary."""
return {
'checkoutid': self.checkoutid,
'machineid': self.machineid,
'sso': self.sso,
'checkout_name': self.checkout_name,
'checkout_reason': self.checkout_reason,
'checkout_time': self.checkout_time.isoformat() if self.checkout_time else None,
'checkin_time': self.checkin_time.isoformat() if self.checkin_time else None,
'was_wiped': self.was_wiped,
'checkin_notes': self.checkin_notes,
'is_checked_out': self.checkin_time is None
}

View File

@@ -60,6 +60,9 @@ class USBDevice(BaseModel, AuditMixin):
# Location # Location
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out') storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
# Security
pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
# Notes # Notes
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
@@ -102,56 +105,51 @@ class USBCheckout(BaseModel):
USB device checkout history. USB device checkout history.
Tracks when devices are checked out and returned. Tracks when devices are checked out and returned.
Maps to existing usbcheckouts table from classic ShopDB.
""" """
__tablename__ = 'usbcheckouts' __tablename__ = 'usbcheckouts'
usbcheckoutid = db.Column(db.Integer, primary_key=True) checkoutid = db.Column(db.Integer, primary_key=True)
# Device reference # Device reference (new column linking to usbdevices table)
usbdeviceid = db.Column( usbdeviceid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
nullable=False nullable=True
) )
# Legacy reference to machines table (kept for backward compatibility)
machineid = db.Column(db.Integer, nullable=False)
# User info # User info
userid = db.Column(db.String(50), nullable=False, comment='SSO of user') sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
username = db.Column(db.String(100), nullable=True, comment='Name of user') checkout_name = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details # Checkout details
checkoutdate = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) checkout_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkindate = db.Column(db.DateTime, nullable=True) checkin_time = db.Column(db.DateTime, nullable=True)
expectedreturndate = db.Column(db.DateTime, nullable=True)
# Metadata # Metadata
purpose = db.Column(db.String(500), nullable=True, comment='Reason for checkout') checkout_reason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
notes = db.Column(db.Text, nullable=True) checkin_notes = db.Column(db.Text, nullable=True)
checkedoutby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkout') was_wiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
checkedinby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkin')
# Relationships # Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
# Indexes
__table_args__ = (
db.Index('idx_usbcheckout_device', 'usbdeviceid'),
db.Index('idx_usbcheckout_user', 'userid'),
db.Index('idx_usbcheckout_dates', 'checkoutdate', 'checkindate'),
)
def __repr__(self): def __repr__(self):
return f"<USBCheckout device={self.usbdeviceid} user={self.userid}>" return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
@property @property
def is_active(self): def is_active(self):
"""Check if this checkout is currently active (not returned).""" """Check if this checkout is currently active (not returned)."""
return self.checkindate is None return self.checkin_time is None
@property @property
def duration_days(self): def duration_days(self):
"""Get duration of checkout in days.""" """Get duration of checkout in days."""
end = self.checkindate or datetime.utcnow() end = self.checkin_time or datetime.utcnow()
delta = end - self.checkoutdate delta = end - self.checkout_time
return delta.days return delta.days
def to_dict(self): def to_dict(self):

View File

@@ -10,7 +10,7 @@ from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.extensions import db
from .models import USBCheckout from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp from .api import usb_bp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,10 +18,10 @@ logger = logging.getLogger(__name__)
class USBPlugin(BasePlugin): class USBPlugin(BasePlugin):
""" """
USB plugin - manages USB device checkouts. USB plugin - manages USB device tracking and checkouts.
USB devices are stored in the machines table (machinetypeid=44). Standalone plugin for tracking USB flash drives, external drives,
This plugin provides checkout/checkin tracking. and other portable storage devices with checkout/checkin functionality.
""" """
def __init__(self): def __init__(self):
@@ -54,7 +54,7 @@ class USBPlugin(BasePlugin):
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [USBCheckout] return [USBDeviceType, USBDevice, USBCheckout]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app.""" """Initialize plugin with Flask app."""

View File

@@ -47,12 +47,12 @@ class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
SQLALCHEMY_ECHO = True SQLALCHEMY_ECHO = True
# Use SQLite for local development if no DATABASE_URL set # Use MySQL from DATABASE_URL
SQLALCHEMY_DATABASE_URI = os.environ.get( SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL', 'DATABASE_URL',
'sqlite:///shopdb_dev.db' 'mysql+pymysql://root:rootpassword@127.0.0.1:3306/shopdb_flask'
) )
SQLALCHEMY_ENGINE_OPTIONS = {} # SQLite doesn't need pool options # Keep pool options from base Config for MySQL
class TestingConfig(Config): class TestingConfig(Config):

View File

@@ -393,7 +393,7 @@ def lookup_asset_by_number(assetnumber: str):
# ============================================================================= # =============================================================================
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET']) @assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_asset_relationships(asset_id: int): def get_asset_relationships(asset_id: int):
""" """
Get all relationships for an asset. Get all relationships for an asset.
@@ -521,7 +521,7 @@ def delete_asset_relationship(rel_id: int):
# ============================================================================= # =============================================================================
@assets_bp.route('/map', methods=['GET']) @assets_bp.route('/map', methods=['GET'])
@jwt_required() @jwt_required(optional=True)
def get_assets_map(): def get_assets_map():
""" """
Get all assets with map positions for unified floor map display. Get all assets with map positions for unified floor map display.
@@ -529,13 +529,14 @@ def get_assets_map():
Returns assets with mapleft/maptop coordinates, joined with type-specific data. Returns assets with mapleft/maptop coordinates, joined with type-specific data.
Query parameters: Query parameters:
- assettype: Filter by asset type name (equipment, computer, network, printer) - assettype: Filter by asset type name (equipment, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for equipment/computer, networkdevicetype for network, printertype for printer)
- businessunitid: Filter by business unit ID - businessunitid: Filter by business unit ID
- statusid: Filter by status ID - statusid: Filter by status ID
- locationid: Filter by location ID - locationid: Filter by location ID
- search: Search by assetnumber, name, or serialnumber - search: Search by assetnumber, name, or serialnumber
""" """
from shopdb.core.models import Location, BusinessUnit from shopdb.core.models import Location, BusinessUnit, MachineType, Machine
query = Asset.query.filter( query = Asset.query.filter(
Asset.isactive == True, Asset.isactive == True,
@@ -543,10 +544,52 @@ def get_assets_map():
Asset.maptop.isnot(None) Asset.maptop.isnot(None)
) )
selected_assettype = request.args.get('assettype')
# Filter by asset type name # Filter by asset type name
if assettype := request.args.get('assettype'): if selected_assettype:
types = assettype.split(',') query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
query = query.join(AssetType).filter(AssetType.assettype.in_(types))
# Filter by subtype (depends on asset type) - case-insensitive matching
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
if asset_type_lower == 'equipment':
# Filter by equipment type
try:
from plugins.equipment.models import Equipment
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
Equipment.equipmenttypeid == subtype_id
)
except ImportError:
pass
elif asset_type_lower == 'computer':
# Filter by computer type
try:
from plugins.computers.models import Computer
query = query.join(Computer, Computer.assetid == Asset.assetid).filter(
Computer.computertypeid == subtype_id
)
except ImportError:
pass
elif asset_type_lower == 'network device':
# Filter by network device type
try:
from plugins.network.models import NetworkDevice
query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter(
NetworkDevice.networkdevicetypeid == subtype_id
)
except ImportError:
pass
elif asset_type_lower == 'printer':
# Filter by printer type
try:
from plugins.printers.models import Printer
query = query.join(Printer, Printer.assetid == Asset.assetid).filter(
Printer.printertypeid == subtype_id
)
except ImportError:
pass
# Filter by business unit # Filter by business unit
if bu_id := request.args.get('businessunitid'): if bu_id := request.args.get('businessunitid'):
@@ -618,6 +661,41 @@ def get_assets_map():
locations = Location.query.filter(Location.isactive == True).all() locations = Location.query.filter(Location.isactive == True).all()
loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations] loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations]
# Get subtypes based on asset type categories (keys match database asset type values)
subtypes = {}
# Equipment types from equipment plugin
try:
from plugins.equipment.models import EquipmentType
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype} for et in equipment_types]
except ImportError:
subtypes['Equipment'] = []
# Computer types from computers plugin
try:
from plugins.computers.models import ComputerType
computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype} for ct in computer_types]
except ImportError:
subtypes['Computer'] = []
# Network device types
try:
from plugins.network.models import NetworkDeviceType
net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype} for nt in net_types]
except ImportError:
subtypes['Network Device'] = []
# Printer types
try:
from plugins.printers.models import PrinterType
printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype} for pt in printer_types]
except ImportError:
subtypes['Printer'] = []
return success_response({ return success_response({
'assets': data, 'assets': data,
'total': len(data), 'total': len(data),
@@ -625,7 +703,8 @@ def get_assets_map():
'assettypes': types_data, 'assettypes': types_data,
'statuses': status_data, 'statuses': status_data,
'businessunits': bu_data, 'businessunits': bu_data,
'locations': loc_data 'locations': loc_data,
'subtypes': subtypes
} }
}) })

View File

@@ -5,7 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
Machine, Application, KnowledgeBase, Application, KnowledgeBase,
Asset, AssetType Asset, AssetType
) )
from shopdb.utils.responses import success_response from shopdb.utils.responses import success_response
@@ -46,74 +46,9 @@ def global_search():
results = [] results = []
search_term = f'%{query}%' search_term = f'%{query}%'
# Search Machines (Equipment and PCs) # NOTE: Legacy Machine search is disabled - all data is now in the Asset table
try: # The Asset search below handles equipment, computers, network devices, and printers
machines = Machine.query.filter( # with proper plugin-specific IDs for correct routing
Machine.isactive == True,
db.or_(
Machine.machinenumber.ilike(search_term),
Machine.alias.ilike(search_term),
Machine.hostname.ilike(search_term),
Machine.serialnumber.ilike(search_term),
Machine.notes.ilike(search_term)
)
).limit(10).all()
except Exception as e:
import logging
logging.error(f"Machine search failed: {e}")
machines = []
for m in machines:
# Determine type: PC, Printer, or Equipment
is_pc = m.pctypeid is not None
is_printer = m.is_printer
# Calculate relevance - exact matches score higher
relevance = 15
if m.machinenumber and query.lower() == m.machinenumber.lower():
relevance = 100
elif m.hostname and query.lower() == m.hostname.lower():
relevance = 100
elif m.alias and query.lower() in m.alias.lower():
relevance = 50
display_name = m.hostname if is_pc and m.hostname else m.machinenumber
if m.alias and not is_pc:
display_name = f"{m.machinenumber} ({m.alias})"
# Determine result type and URL
if is_printer:
result_type = 'printer'
url = f"/printers/{m.machineid}"
elif is_pc:
result_type = 'pc'
url = f"/pcs/{m.machineid}"
else:
result_type = 'machine'
url = f"/machines/{m.machineid}"
# Get location - prefer machine's own location, fall back to parent machine's location
location_name = None
if m.location:
location_name = m.location.locationname
elif m.parent_relationships:
# Check parent machines for location
for rel in m.parent_relationships:
if rel.parent_machine and rel.parent_machine.location:
location_name = rel.parent_machine.location.locationname
break
# Get machinetype from model (single source of truth)
mt = m.derived_machinetype
results.append({
'type': result_type,
'id': m.machineid,
'title': display_name,
'subtitle': mt.machinetype if mt else None,
'location': location_name,
'url': url,
'relevance': relevance
})
# Search Applications # Search Applications
try: try:
@@ -173,37 +108,8 @@ def global_search():
import logging import logging
logging.error(f"KnowledgeBase search failed: {e}") logging.error(f"KnowledgeBase search failed: {e}")
# Search Printers (check if printers model exists) # NOTE: Legacy Printer search removed - printers are now in the unified Asset table
try: # The Asset search below handles printers with correct plugin-specific IDs
from shopdb.plugins.printers.models import Printer
printers = Printer.query.filter(
Printer.isactive == True,
db.or_(
Printer.printercsfname.ilike(search_term),
Printer.printerwindowsname.ilike(search_term),
Printer.serialnumber.ilike(search_term),
Printer.fqdn.ilike(search_term)
)
).limit(10).all()
for p in printers:
relevance = 15
if p.printercsfname and query.lower() == p.printercsfname.lower():
relevance = 100
display_name = p.printercsfname or p.printerwindowsname or f"Printer #{p.printerid}"
results.append({
'type': 'printer',
'id': p.printerid,
'title': display_name,
'subtitle': p.printerwindowsname if p.printercsfname else None,
'url': f"/printers/{p.printerid}",
'relevance': relevance
})
except Exception as e:
import logging
logging.error(f"Printer search failed: {e}")
# Search Employees (separate database) # Search Employees (separate database)
try: try:
@@ -281,11 +187,23 @@ def global_search():
# Determine URL and type based on asset type # Determine URL and type based on asset type
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset' asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
# Get the plugin-specific ID for proper routing
plugin_id = asset.assetid # fallback
if asset_type_name == 'equipment' and hasattr(asset, 'equipment') and asset.equipment:
plugin_id = asset.equipment.equipmentid
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
plugin_id = asset.computer.computerid
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
plugin_id = asset.network_device.networkdeviceid
elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer:
plugin_id = asset.printer.printerid
url_map = { url_map = {
'equipment': f"/equipment/{asset.assetid}", 'equipment': f"/machines/{plugin_id}",
'computer': f"/pcs/{asset.assetid}", 'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{asset.assetid}", 'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{asset.assetid}", 'printer': f"/printers/{plugin_id}",
} }
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}") url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
@@ -299,7 +217,7 @@ def global_search():
results.append({ results.append({
'type': asset_type_name, 'type': asset_type_name,
'id': asset.assetid, 'id': plugin_id,
'title': display_name, 'title': display_name,
'subtitle': subtitle, 'subtitle': subtitle,
'location': location_name, 'location': location_name,

View File

@@ -156,12 +156,52 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
comm = self.communications.filter_by(comtypeid=1).first() comm = self.communications.filter_by(comtypeid=1).first()
return comm.ipaddress if comm else None return comm.ipaddress if comm else None
def to_dict(self, include_type_data=False): def get_inherited_location(self):
"""
Get location data from a related asset if this asset has none.
Returns dict with locationid, location_name, mapleft, maptop, and
inherited_from (assetnumber of source asset) if location was inherited.
Returns None if no location data available.
"""
# If we have our own location data, don't inherit
if self.locationid is not None or (self.mapleft is not None and self.maptop is not None):
return None
# Check related assets for location data
# Look in both incoming and outgoing relationships
related_assets = []
if hasattr(self, 'incoming_relationships'):
for rel in self.incoming_relationships:
if rel.source_asset and rel.isactive:
related_assets.append(rel.source_asset)
if hasattr(self, 'outgoing_relationships'):
for rel in self.outgoing_relationships:
if rel.target_asset and rel.isactive:
related_assets.append(rel.target_asset)
# Find first related asset with location data
for related in related_assets:
if related.locationid is not None or (related.mapleft is not None and related.maptop is not None):
return {
'locationid': related.locationid,
'location_name': related.location.locationname if related.location else None,
'mapleft': related.mapleft,
'maptop': related.maptop,
'inherited_from': related.assetnumber
}
return None
def to_dict(self, include_type_data=False, include_inherited_location=True):
""" """
Convert model to dictionary. Convert model to dictionary.
Args: Args:
include_type_data: If True, include category-specific data from extension table include_type_data: If True, include category-specific data from extension table
include_inherited_location: If True, include location from related assets when missing
""" """
result = super().to_dict() result = super().to_dict()
@@ -175,6 +215,30 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if self.businessunit: if self.businessunit:
result['businessunit_name'] = self.businessunit.businessunit result['businessunit_name'] = self.businessunit.businessunit
# Add plugin-specific ID for navigation purposes
if hasattr(self, 'equipment') and self.equipment:
result['plugin_id'] = self.equipment.equipmentid
elif hasattr(self, 'computer') and self.computer:
result['plugin_id'] = self.computer.computerid
elif hasattr(self, 'network_device') and self.network_device:
result['plugin_id'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer:
result['plugin_id'] = self.printer.printerid
# Include inherited location if this asset has no location data
if include_inherited_location:
inherited = self.get_inherited_location()
if inherited:
result['inherited_location'] = inherited
# Also set the location fields if they're missing
if result.get('locationid') is None:
result['locationid'] = inherited['locationid']
result['location_name'] = inherited['location_name']
if result.get('mapleft') is None:
result['mapleft'] = inherited['mapleft']
if result.get('maptop') is None:
result['maptop'] = inherited['maptop']
# Include extension data if requested # Include extension data if requested
if include_type_data: if include_type_data:
ext_data = self._get_extension_data() ext_data = self._get_extension_data()

View File

@@ -11,4 +11,4 @@ from shopdb import create_app
app = create_app(os.environ.get('FLASK_ENV', 'development')) app = create_app(os.environ.get('FLASK_ENV', 'development'))
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5001, debug=True)