Add full search parity and optimize map performance
Search: refactor into modular helpers, add IP/hostname/notification/ vendor/model/type search, smart auto-redirects for exact matches, ServiceNOW prefix detection, filter buttons with counts, share links with highlight support, and dark mode badge colors. Map: fix N+1 queries with eager loading (248->28 queries), switch to canvas-rendered circleMarkers for better performance with 500+ assets. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,16 @@ const api = axios.create({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add auth token to requests
|
// Add auth token and normalize pagination params
|
||||||
api.interceptors.request.use(config => {
|
api.interceptors.request.use(config => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
// Send both perpage and per_page for backend compatibility
|
||||||
|
if (config.params?.perpage) {
|
||||||
|
config.params.per_page = config.params.perpage
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -306,6 +310,9 @@ export const printersApi = {
|
|||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
summary() {
|
summary() {
|
||||||
return api.get('/dashboard/summary')
|
return api.get('/dashboard/summary')
|
||||||
|
},
|
||||||
|
navigation() {
|
||||||
|
return api.get('/dashboard/navigation')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,8 +451,8 @@ export const applicationsApi = {
|
|||||||
|
|
||||||
// Search API
|
// Search API
|
||||||
export const searchApi = {
|
export const searchApi = {
|
||||||
search(query) {
|
search(query, params = {}) {
|
||||||
return api.get('/search', { params: { q: query } })
|
return api.get('/search', { params: { q: query, ...params } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,6 +646,9 @@ export const reportsApi = {
|
|||||||
},
|
},
|
||||||
assetInventory(params = {}) {
|
assetInventory(params = {}) {
|
||||||
return api.get('/reports/asset-inventory', { params })
|
return api.get('/reports/asset-inventory', { params })
|
||||||
|
},
|
||||||
|
pcRelationships(params = {}) {
|
||||||
|
return api.get('/reports/pc-relationships', { params })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ let imageOverlay = null
|
|||||||
const markers = ref([])
|
const markers = ref([])
|
||||||
const pickedPosition = ref(null)
|
const pickedPosition = ref(null)
|
||||||
let pickerMarker = null
|
let pickerMarker = null
|
||||||
|
let markerLayer = null
|
||||||
|
let canvasRenderer = null
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
machinetype: '',
|
machinetype: '',
|
||||||
@@ -258,11 +260,15 @@ const visibleSubtypes = computed(() => {
|
|||||||
function initMap() {
|
function initMap() {
|
||||||
if (!mapContainer.value) return
|
if (!mapContainer.value) return
|
||||||
|
|
||||||
|
// Use canvas renderer for performance - renders all markers on a single <canvas>
|
||||||
|
canvasRenderer = L.canvas({ padding: 0.5 })
|
||||||
|
|
||||||
map = L.map(mapContainer.value, {
|
map = L.map(mapContainer.value, {
|
||||||
crs: L.CRS.Simple,
|
crs: L.CRS.Simple,
|
||||||
minZoom: -4,
|
minZoom: -4,
|
||||||
maxZoom: 2,
|
maxZoom: 2,
|
||||||
attributionControl: false
|
attributionControl: false,
|
||||||
|
renderer: canvasRenderer
|
||||||
})
|
})
|
||||||
|
|
||||||
const blueprintUrl = props.theme === 'light'
|
const blueprintUrl = props.theme === 'light'
|
||||||
@@ -397,21 +403,21 @@ function renderMarkers() {
|
|||||||
detailRoute = getDetailRoute(item)
|
detailRoute = getDetailRoute(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = L.divIcon({
|
// Use circleMarker instead of divIcon marker - renders on canvas
|
||||||
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
|
// for much better performance (no DOM element per marker)
|
||||||
iconSize: [12, 12],
|
const marker = L.circleMarker([leafletY, leafletX], {
|
||||||
iconAnchor: [6, 6],
|
radius: 6,
|
||||||
popupAnchor: [0, -6],
|
fillColor: color,
|
||||||
className: 'machine-marker'
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
weight: 2,
|
||||||
|
fillOpacity: 1,
|
||||||
|
renderer: canvasRenderer
|
||||||
})
|
})
|
||||||
|
|
||||||
const marker = L.marker([leafletY, leafletX], { icon })
|
|
||||||
|
|
||||||
// Build tooltip content
|
// Build tooltip content
|
||||||
let tooltipLines = [`<strong>${displayName}</strong>`]
|
let tooltipLines = [`<strong>${displayName}</strong>`]
|
||||||
|
|
||||||
if (props.assetTypeMode) {
|
if (props.assetTypeMode) {
|
||||||
// Asset mode tooltips
|
|
||||||
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
|
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
|
||||||
|
|
||||||
if (item.primaryip) {
|
if (item.primaryip) {
|
||||||
@@ -421,7 +427,6 @@ function renderMarkers() {
|
|||||||
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
|
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy machine mode tooltips
|
|
||||||
const category = item.category?.toLowerCase() || ''
|
const category = item.category?.toLowerCase() || ''
|
||||||
|
|
||||||
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
||||||
@@ -448,7 +453,6 @@ function renderMarkers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business unit
|
|
||||||
if (item.businessunit) {
|
if (item.businessunit) {
|
||||||
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
|
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
|
||||||
}
|
}
|
||||||
@@ -561,7 +565,7 @@ function applyFilters() {
|
|||||||
visible = false
|
visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
marker.setOpacity(visible ? 1 : 0.15)
|
marker.setStyle({ fillOpacity: visible ? 1 : 0.15, opacity: visible ? 1 : 0.15 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,9 +575,9 @@ function debounceSearch() {
|
|||||||
searchTimeout = setTimeout(applyFilters, 300)
|
searchTimeout = setTimeout(applyFilters, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.machines, () => {
|
watch(() => props.machines, (newVal, oldVal) => {
|
||||||
if (map) renderMarkers()
|
if (map && newVal !== oldVal) renderMarkers()
|
||||||
}, { deep: true })
|
})
|
||||||
|
|
||||||
watch(() => props.theme, (newTheme) => {
|
watch(() => props.theme, (newTheme) => {
|
||||||
if (imageOverlay && map) {
|
if (imageOverlay && map) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Search Results</h2>
|
<h2>Search Results</h2>
|
||||||
<span v-if="results.length" class="results-count">
|
<span v-if="results.length" class="results-count">
|
||||||
{{ results.length }} result{{ results.length !== 1 ? 's' : '' }} for "{{ query }}"
|
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -12,25 +12,45 @@
|
|||||||
v-model="searchInput"
|
v-model="searchInput"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Search machines, applications, knowledge base..."
|
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
|
||||||
@keyup.enter="performSearch"
|
@keyup.enter="performSearch"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-primary" @click="performSearch">Search</button>
|
<button class="btn btn-primary" @click="performSearch">Search</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results.length" class="filter-buttons">
|
||||||
|
<button
|
||||||
|
v-for="filter in filterList"
|
||||||
|
:key="filter.key"
|
||||||
|
class="filter-btn"
|
||||||
|
:class="{ active: activeFilter === filter.key }"
|
||||||
|
@click="activeFilter = filter.key"
|
||||||
|
>
|
||||||
|
{{ filter.label }}
|
||||||
|
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="loading">Searching...</div>
|
<div v-if="loading" class="loading">Searching...</div>
|
||||||
|
|
||||||
<template v-else-if="query">
|
<template v-else-if="query">
|
||||||
<div v-if="results.length === 0" class="no-results">
|
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
|
||||||
|
No {{ activeFilter }} results for "{{ query }}"
|
||||||
|
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="results.length === 0" class="no-results">
|
||||||
No results found for "{{ query }}"
|
No results found for "{{ query }}"
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="results-list">
|
<div v-else class="results-list">
|
||||||
<div
|
<div
|
||||||
v-for="result in results"
|
v-for="result in filteredResults"
|
||||||
:key="`${result.type}-${result.id}`"
|
:key="`${result.type}-${result.id}`"
|
||||||
|
:id="`result-${result.type}-${result.id}`"
|
||||||
class="result-item"
|
class="result-item"
|
||||||
|
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
|
||||||
>
|
>
|
||||||
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
||||||
<div class="result-content">
|
<div class="result-content">
|
||||||
@@ -48,21 +68,30 @@
|
|||||||
<div class="result-meta">
|
<div class="result-meta">
|
||||||
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
||||||
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
||||||
|
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
||||||
|
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="share-btn"
|
||||||
|
@click="shareResult(result)"
|
||||||
|
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
|
||||||
|
>
|
||||||
|
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="no-results">
|
<div v-else class="no-results">
|
||||||
Enter a search term to find machines, applications, printers, and knowledge base articles.
|
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { searchApi, knowledgebaseApi } from '../api'
|
import { searchApi, knowledgebaseApi } from '../api'
|
||||||
|
|
||||||
@@ -73,19 +102,68 @@ const loading = ref(false)
|
|||||||
const results = ref([])
|
const results = ref([])
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
|
const activeFilter = ref('all')
|
||||||
|
const typeCounts = ref({})
|
||||||
|
const totalAll = ref(0)
|
||||||
|
const highlightId = ref(null)
|
||||||
|
const copiedId = ref(null)
|
||||||
|
|
||||||
const typeLabels = {
|
const typeLabels = {
|
||||||
machine: 'Equipment',
|
machine: 'Equipment',
|
||||||
|
equipment: 'Equipment',
|
||||||
pc: 'PC',
|
pc: 'PC',
|
||||||
|
computer: 'PC',
|
||||||
application: 'App',
|
application: 'App',
|
||||||
knowledgebase: 'KB',
|
knowledgebase: 'KB',
|
||||||
printer: 'Printer'
|
printer: 'Printer',
|
||||||
|
network_device: 'Network',
|
||||||
|
employee: 'Employee',
|
||||||
|
notification: 'Notice',
|
||||||
|
subnet: 'Subnet'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterTypeMap = {
|
||||||
|
all: null,
|
||||||
|
equipment: ['equipment'],
|
||||||
|
computers: ['computer'],
|
||||||
|
printers: ['printer'],
|
||||||
|
network: ['network_device', 'subnet'],
|
||||||
|
applications: ['application'],
|
||||||
|
knowledgebase: ['knowledgebase'],
|
||||||
|
notifications: ['notification'],
|
||||||
|
employees: ['employee']
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterList = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'computers', label: 'PCs' },
|
||||||
|
{ key: 'printers', label: 'Printers' },
|
||||||
|
{ key: 'network', label: 'Network' },
|
||||||
|
{ key: 'applications', label: 'Apps' },
|
||||||
|
{ key: 'knowledgebase', label: 'KB' },
|
||||||
|
{ key: 'notifications', label: 'Notices' },
|
||||||
|
{ key: 'employees', label: 'Employees' }
|
||||||
|
]
|
||||||
|
|
||||||
function typeLabel(type) {
|
function typeLabel(type) {
|
||||||
return typeLabels[type] || type
|
return typeLabels[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterCount(filterKey) {
|
||||||
|
if (filterKey === 'all') return totalAll.value || results.value.length
|
||||||
|
const types = filterTypeMap[filterKey]
|
||||||
|
if (!types) return 0
|
||||||
|
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredResults = computed(() => {
|
||||||
|
if (activeFilter.value === 'all') return results.value
|
||||||
|
const types = filterTypeMap[activeFilter.value]
|
||||||
|
if (!types) return results.value
|
||||||
|
return results.value.filter(r => types.includes(r.type))
|
||||||
|
})
|
||||||
|
|
||||||
async function search(q) {
|
async function search(q) {
|
||||||
if (!q || q.length < 2) {
|
if (!q || q.length < 2) {
|
||||||
results.value = []
|
results.value = []
|
||||||
@@ -93,9 +171,30 @@ async function search(q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
activeFilter.value = 'all'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await searchApi.search(q)
|
const response = await searchApi.search(q)
|
||||||
results.value = response.data.data?.results || []
|
const data = response.data.data
|
||||||
|
|
||||||
|
// Handle ServiceNOW redirect
|
||||||
|
if (data?.redirect?.type === 'servicenow') {
|
||||||
|
window.open(data.redirect.url, '_blank')
|
||||||
|
query.value = q
|
||||||
|
results.value = []
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart redirect - auto-navigate to exact match
|
||||||
|
if (data?.redirect) {
|
||||||
|
router.replace(data.redirect.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results.value = data?.results || []
|
||||||
|
typeCounts.value = data?.counts || {}
|
||||||
|
totalAll.value = data?.total_all || results.value.length
|
||||||
query.value = q
|
query.value = q
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error)
|
console.error('Search error:', error)
|
||||||
@@ -112,7 +211,6 @@ function performSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openKBArticle(result) {
|
async function openKBArticle(result) {
|
||||||
// Track the click before opening
|
|
||||||
try {
|
try {
|
||||||
await knowledgebaseApi.trackClick(result.id)
|
await knowledgebaseApi.trackClick(result.id)
|
||||||
if (result.linkurl) {
|
if (result.linkurl) {
|
||||||
@@ -130,8 +228,31 @@ async function openKBArticle(result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shareResult(result) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('highlight', `${result.type}-${result.id}`)
|
||||||
|
navigator.clipboard.writeText(url.toString()).then(() => {
|
||||||
|
copiedId.value = `${result.type}-${result.id}`
|
||||||
|
setTimeout(() => { copiedId.value = null }, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHighlight() {
|
||||||
|
if (highlightId.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = document.getElementById(`result-${highlightId.value}`)
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
setTimeout(() => { highlightId.value = null }, 3000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const q = route.query.q
|
const q = route.query.q
|
||||||
|
const hl = route.query.highlight
|
||||||
|
if (hl) highlightId.value = hl
|
||||||
if (q) {
|
if (q) {
|
||||||
searchInput.value = q
|
searchInput.value = q
|
||||||
search(q)
|
search(q)
|
||||||
@@ -141,9 +262,15 @@ onMounted(() => {
|
|||||||
watch(() => route.query.q, (newQ) => {
|
watch(() => route.query.q, (newQ) => {
|
||||||
if (newQ) {
|
if (newQ) {
|
||||||
searchInput.value = newQ
|
searchInput.value = newQ
|
||||||
|
const hl = route.query.highlight
|
||||||
|
if (hl) highlightId.value = hl
|
||||||
search(newQ)
|
search(newQ)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(results, () => {
|
||||||
|
scrollToHighlight()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -159,7 +286,7 @@ watch(() => route.query.q, (newQ) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.results-count {
|
.results-count {
|
||||||
color: var(--text-light, #666);
|
color: var(--text-light);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,9 +300,52 @@ watch(() => route.query.q, (newQ) => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover:not(.active) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-count {
|
||||||
|
background: rgba(128, 128, 128, 0.15);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active .filter-count {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.no-results {
|
.no-results {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-light, #666);
|
color: var(--text-light);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,29 +359,38 @@ watch(() => route.query.q, (newQ) => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e5e5e5);
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item:last-child {
|
.result-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-type {
|
.result-item.highlighted {
|
||||||
font-size: 0.75rem;
|
background: rgba(65, 129, 255, 0.08);
|
||||||
font-weight: 600;
|
border-left: 3px solid var(--primary);
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-width: 70px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-type.machine {
|
.result-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 65px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.machine,
|
||||||
|
.result-type.equipment {
|
||||||
background: #e3f2fd;
|
background: #e3f2fd;
|
||||||
color: #1565c0;
|
color: #1565c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-type.pc {
|
.result-type.pc,
|
||||||
|
.result-type.computer {
|
||||||
background: #e8f5e9;
|
background: #e8f5e9;
|
||||||
color: #2e7d32;
|
color: #2e7d32;
|
||||||
}
|
}
|
||||||
@@ -231,12 +410,33 @@ watch(() => route.query.q, (newQ) => {
|
|||||||
color: #c2185b;
|
color: #c2185b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-type.network_device {
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #f57f17;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.employee {
|
||||||
|
background: #e0f2f1;
|
||||||
|
color: #00695c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.notification {
|
||||||
|
background: #e8eaf6;
|
||||||
|
color: #283593;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.subnet {
|
||||||
|
background: #fbe9e7;
|
||||||
|
color: #bf360c;
|
||||||
|
}
|
||||||
|
|
||||||
.result-content {
|
.result-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-title {
|
.result-title {
|
||||||
color: var(--primary, #1976d2);
|
color: var(--link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -247,21 +447,88 @@ watch(() => route.query.q, (newQ) => {
|
|||||||
|
|
||||||
.result-meta {
|
.result-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-light, #666);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-subtitle {
|
.result-subtitle {
|
||||||
color: var(--text-light, #666);
|
color: var(--text-light);
|
||||||
}
|
|
||||||
|
|
||||||
.result-location {
|
|
||||||
color: var(--text-light, #666);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-location::before {
|
.result-location::before {
|
||||||
content: '\1F4CD ';
|
content: '\1F4CD ';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-ticket {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.result-type.machine,
|
||||||
|
.result-type.equipment {
|
||||||
|
background: rgba(21, 101, 192, 0.2);
|
||||||
|
color: #64b5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.pc,
|
||||||
|
.result-type.computer {
|
||||||
|
background: rgba(46, 125, 50, 0.2);
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.application {
|
||||||
|
background: rgba(230, 81, 0, 0.2);
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.knowledgebase {
|
||||||
|
background: rgba(123, 31, 162, 0.2);
|
||||||
|
color: #ce93d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.printer {
|
||||||
|
background: rgba(194, 24, 91, 0.2);
|
||||||
|
color: #f48fb1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.network_device {
|
||||||
|
background: rgba(245, 127, 23, 0.2);
|
||||||
|
color: #ffd54f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.employee {
|
||||||
|
background: rgba(0, 105, 92, 0.2);
|
||||||
|
color: #80cbc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.notification {
|
||||||
|
background: rgba(40, 53, 147, 0.2);
|
||||||
|
color: #9fa8da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.subnet {
|
||||||
|
background: rgba(191, 54, 12, 0.2);
|
||||||
|
color: #ffab91;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
from sqlalchemy.orm import joinedload, subqueryload
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType
|
from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType
|
||||||
@@ -73,8 +74,8 @@ def create_asset_type():
|
|||||||
|
|
||||||
t = AssetType(
|
t = AssetType(
|
||||||
assettype=data['assettype'],
|
assettype=data['assettype'],
|
||||||
plugin_name=data.get('plugin_name'),
|
pluginname=data.get('pluginname'),
|
||||||
table_name=data.get('table_name'),
|
tablename=data.get('tablename'),
|
||||||
description=data.get('description'),
|
description=data.get('description'),
|
||||||
icon=data.get('icon')
|
icon=data.get('icon')
|
||||||
)
|
)
|
||||||
@@ -411,26 +412,26 @@ def get_asset_relationships(asset_id: int):
|
|||||||
|
|
||||||
# Get outgoing relationships (this asset is source)
|
# Get outgoing relationships (this asset is source)
|
||||||
outgoing = AssetRelationship.query.filter_by(
|
outgoing = AssetRelationship.query.filter_by(
|
||||||
source_assetid=asset_id
|
sourceassetid=asset_id
|
||||||
).filter(AssetRelationship.isactive == True).all()
|
).filter(AssetRelationship.isactive == True).all()
|
||||||
|
|
||||||
# Get incoming relationships (this asset is target)
|
# Get incoming relationships (this asset is target)
|
||||||
incoming = AssetRelationship.query.filter_by(
|
incoming = AssetRelationship.query.filter_by(
|
||||||
target_assetid=asset_id
|
targetassetid=asset_id
|
||||||
).filter(AssetRelationship.isactive == True).all()
|
).filter(AssetRelationship.isactive == True).all()
|
||||||
|
|
||||||
outgoing_data = []
|
outgoing_data = []
|
||||||
for rel in outgoing:
|
for rel in outgoing:
|
||||||
r = rel.to_dict()
|
r = rel.to_dict()
|
||||||
r['target_asset'] = rel.target_asset.to_dict() if rel.target_asset else None
|
r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None
|
||||||
r['relationship_type_name'] = rel.relationship_type.relationshiptype if rel.relationship_type else None
|
r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
|
||||||
outgoing_data.append(r)
|
outgoing_data.append(r)
|
||||||
|
|
||||||
incoming_data = []
|
incoming_data = []
|
||||||
for rel in incoming:
|
for rel in incoming:
|
||||||
r = rel.to_dict()
|
r = rel.to_dict()
|
||||||
r['source_asset'] = rel.source_asset.to_dict() if rel.source_asset else None
|
r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None
|
||||||
r['relationship_type_name'] = rel.relationship_type.relationshiptype if rel.relationship_type else None
|
r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
|
||||||
incoming_data.append(r)
|
incoming_data.append(r)
|
||||||
|
|
||||||
return success_response({
|
return success_response({
|
||||||
@@ -449,13 +450,13 @@ def create_asset_relationship():
|
|||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
required = ['source_assetid', 'target_assetid', 'relationshiptypeid']
|
required = ['sourceassetid', 'targetassetid', 'relationshiptypeid']
|
||||||
for field in required:
|
for field in required:
|
||||||
if not data.get(field):
|
if not data.get(field):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required')
|
return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required')
|
||||||
|
|
||||||
source_id = data['source_assetid']
|
source_id = data['sourceassetid']
|
||||||
target_id = data['target_assetid']
|
target_id = data['targetassetid']
|
||||||
type_id = data['relationshiptypeid']
|
type_id = data['relationshiptypeid']
|
||||||
|
|
||||||
# Validate assets exist
|
# Validate assets exist
|
||||||
@@ -468,8 +469,8 @@ def create_asset_relationship():
|
|||||||
|
|
||||||
# Check for duplicate relationship
|
# Check for duplicate relationship
|
||||||
existing = AssetRelationship.query.filter_by(
|
existing = AssetRelationship.query.filter_by(
|
||||||
source_assetid=source_id,
|
sourceassetid=source_id,
|
||||||
target_assetid=target_id,
|
targetassetid=target_id,
|
||||||
relationshiptypeid=type_id
|
relationshiptypeid=type_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -481,8 +482,8 @@ def create_asset_relationship():
|
|||||||
)
|
)
|
||||||
|
|
||||||
rel = AssetRelationship(
|
rel = AssetRelationship(
|
||||||
source_assetid=source_id,
|
sourceassetid=source_id,
|
||||||
target_assetid=target_id,
|
targetassetid=target_id,
|
||||||
relationshiptypeid=type_id,
|
relationshiptypeid=type_id,
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -536,9 +537,85 @@ def get_assets_map():
|
|||||||
- 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, MachineType, Machine
|
from shopdb.core.models import Location, BusinessUnit, MachineType, Machine, Communication
|
||||||
|
|
||||||
query = Asset.query.filter(
|
# Eager-load all relationships to avoid N+1 queries.
|
||||||
|
# Core relationships via joinedload, extension tables via subqueryload
|
||||||
|
# with their nested relationships (vendor, model, type) also eager-loaded.
|
||||||
|
eager_options = [
|
||||||
|
joinedload(Asset.assettype),
|
||||||
|
joinedload(Asset.status),
|
||||||
|
joinedload(Asset.location),
|
||||||
|
joinedload(Asset.businessunit),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Eager-load plugin extension tables AND their relationships
|
||||||
|
try:
|
||||||
|
from plugins.equipment.models import Equipment
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.equipment)
|
||||||
|
.joinedload(Equipment.equipmenttype)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.equipment)
|
||||||
|
.joinedload(Equipment.vendor)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.equipment)
|
||||||
|
.joinedload(Equipment.model)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.equipment)
|
||||||
|
.joinedload(Equipment.controllervendor)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.equipment)
|
||||||
|
.joinedload(Equipment.controllermodel)
|
||||||
|
)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.computer)
|
||||||
|
.joinedload(Computer.computertype)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.computer)
|
||||||
|
.joinedload(Computer.operatingsystem)
|
||||||
|
)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from plugins.network.models import NetworkDevice
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.network_device)
|
||||||
|
.joinedload(NetworkDevice.networkdevicetype)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.network_device)
|
||||||
|
.joinedload(NetworkDevice.vendor)
|
||||||
|
)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from plugins.printers.models import Printer
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.printer)
|
||||||
|
.joinedload(Printer.printertype)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.printer)
|
||||||
|
.joinedload(Printer.vendor)
|
||||||
|
)
|
||||||
|
eager_options.append(
|
||||||
|
subqueryload(Asset.printer)
|
||||||
|
.joinedload(Printer.model)
|
||||||
|
)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
query = Asset.query.options(*eager_options).filter(
|
||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
Asset.mapleft.isnot(None),
|
Asset.mapleft.isnot(None),
|
||||||
Asset.maptop.isnot(None)
|
Asset.maptop.isnot(None)
|
||||||
@@ -555,7 +632,6 @@ def get_assets_map():
|
|||||||
subtype_id = int(subtype_id)
|
subtype_id = int(subtype_id)
|
||||||
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
|
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
|
||||||
if asset_type_lower == 'equipment':
|
if asset_type_lower == 'equipment':
|
||||||
# Filter by equipment type
|
|
||||||
try:
|
try:
|
||||||
from plugins.equipment.models import Equipment
|
from plugins.equipment.models import Equipment
|
||||||
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
|
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
|
||||||
@@ -564,7 +640,6 @@ def get_assets_map():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
elif asset_type_lower == 'computer':
|
elif asset_type_lower == 'computer':
|
||||||
# Filter by computer type
|
|
||||||
try:
|
try:
|
||||||
from plugins.computers.models import Computer
|
from plugins.computers.models import Computer
|
||||||
query = query.join(Computer, Computer.assetid == Asset.assetid).filter(
|
query = query.join(Computer, Computer.assetid == Asset.assetid).filter(
|
||||||
@@ -573,7 +648,6 @@ def get_assets_map():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
elif asset_type_lower == 'network device':
|
elif asset_type_lower == 'network device':
|
||||||
# Filter by network device type
|
|
||||||
try:
|
try:
|
||||||
from plugins.network.models import NetworkDevice
|
from plugins.network.models import NetworkDevice
|
||||||
query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter(
|
query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter(
|
||||||
@@ -582,7 +656,6 @@ def get_assets_map():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
elif asset_type_lower == 'printer':
|
elif asset_type_lower == 'printer':
|
||||||
# Filter by printer type
|
|
||||||
try:
|
try:
|
||||||
from plugins.printers.models import Printer
|
from plugins.printers.models import Printer
|
||||||
query = query.join(Printer, Printer.assetid == Asset.assetid).filter(
|
query = query.join(Printer, Printer.assetid == Asset.assetid).filter(
|
||||||
@@ -615,7 +688,29 @@ def get_assets_map():
|
|||||||
|
|
||||||
assets = query.all()
|
assets = query.all()
|
||||||
|
|
||||||
# Build response with type-specific data
|
# Batch-load primary IPs in a single query instead of N+1 per asset.
|
||||||
|
# Prefer isprimary=True IP, fall back to any IP (comtypeid=1).
|
||||||
|
asset_ids = [a.assetid for a in assets]
|
||||||
|
primary_ip_map = {}
|
||||||
|
if asset_ids:
|
||||||
|
ip_rows = db.session.query(
|
||||||
|
Communication.assetid,
|
||||||
|
Communication.ipaddress,
|
||||||
|
Communication.isprimary
|
||||||
|
).filter(
|
||||||
|
Communication.assetid.in_(asset_ids),
|
||||||
|
Communication.comtypeid == 1,
|
||||||
|
Communication.isactive == True
|
||||||
|
).order_by(
|
||||||
|
Communication.isprimary.desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for row in ip_rows:
|
||||||
|
# First match wins (isprimary=True sorted first)
|
||||||
|
if row.assetid not in primary_ip_map:
|
||||||
|
primary_ip_map[row.assetid] = row.ipaddress
|
||||||
|
|
||||||
|
# Build response - all relationship data is already loaded, no extra queries
|
||||||
data = []
|
data = []
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
item = {
|
item = {
|
||||||
@@ -635,36 +730,32 @@ def get_assets_map():
|
|||||||
'locationid': asset.locationid,
|
'locationid': asset.locationid,
|
||||||
'businessunit': asset.businessunit.businessunit if asset.businessunit else None,
|
'businessunit': asset.businessunit.businessunit if asset.businessunit else None,
|
||||||
'businessunitid': asset.businessunitid,
|
'businessunitid': asset.businessunitid,
|
||||||
'primaryip': asset.primary_ip,
|
'primaryip': primary_ip_map.get(asset.assetid),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add type-specific data
|
# Extension data is already eager-loaded via lazy='joined' backrefs
|
||||||
type_data = asset._get_extension_data()
|
type_data = asset._get_extension_data()
|
||||||
if type_data:
|
if type_data:
|
||||||
item['typedata'] = type_data
|
item['typedata'] = type_data
|
||||||
|
|
||||||
data.append(item)
|
data.append(item)
|
||||||
|
|
||||||
# Get available asset types for filters
|
# Get filter options - these are small reference tables, no N+1 concern
|
||||||
asset_types = AssetType.query.filter(AssetType.isactive == True).all()
|
asset_types = AssetType.query.filter(AssetType.isactive == True).all()
|
||||||
types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types]
|
types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types]
|
||||||
|
|
||||||
# Get status options
|
|
||||||
statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all()
|
statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all()
|
||||||
status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses]
|
status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses]
|
||||||
|
|
||||||
# Get business units
|
|
||||||
business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all()
|
business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all()
|
||||||
bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units]
|
bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units]
|
||||||
|
|
||||||
# Get locations
|
|
||||||
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)
|
# Get subtypes for filter dropdowns
|
||||||
subtypes = {}
|
subtypes = {}
|
||||||
|
|
||||||
# Equipment types from equipment plugin
|
|
||||||
try:
|
try:
|
||||||
from plugins.equipment.models import EquipmentType
|
from plugins.equipment.models import EquipmentType
|
||||||
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
|
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
|
||||||
@@ -672,7 +763,6 @@ def get_assets_map():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
subtypes['Equipment'] = []
|
subtypes['Equipment'] = []
|
||||||
|
|
||||||
# Computer types from computers plugin
|
|
||||||
try:
|
try:
|
||||||
from plugins.computers.models import ComputerType
|
from plugins.computers.models import ComputerType
|
||||||
computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
|
computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
|
||||||
@@ -680,7 +770,6 @@ def get_assets_map():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
subtypes['Computer'] = []
|
subtypes['Computer'] = []
|
||||||
|
|
||||||
# Network device types
|
|
||||||
try:
|
try:
|
||||||
from plugins.network.models import NetworkDeviceType
|
from plugins.network.models import NetworkDeviceType
|
||||||
net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
|
net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
|
||||||
@@ -688,7 +777,6 @@ def get_assets_map():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
subtypes['Network Device'] = []
|
subtypes['Network Device'] = []
|
||||||
|
|
||||||
# Printer types
|
|
||||||
try:
|
try:
|
||||||
from plugins.printers.models import PrinterType
|
from plugins.printers.models import PrinterType
|
||||||
printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
|
printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
|
||||||
|
|||||||
@@ -1,56 +1,91 @@
|
|||||||
"""Global search API endpoint."""
|
"""Global search API endpoint with full search parity."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import (
|
from shopdb.core.models import (
|
||||||
Application, KnowledgeBase,
|
Application, KnowledgeBase,
|
||||||
Asset, AssetType
|
Asset, AssetType, Communication, Vendor, Model
|
||||||
)
|
)
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
search_bp = Blueprint('search', __name__)
|
search_bp = Blueprint('search', __name__)
|
||||||
|
|
||||||
|
# ServiceNOW URL template
|
||||||
|
SERVICENOW_URL = (
|
||||||
|
'https://geit.service-now.com/now/nav/ui/search/'
|
||||||
|
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
|
||||||
|
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||||
|
'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui'
|
||||||
|
)
|
||||||
|
|
||||||
@search_bp.route('', methods=['GET'])
|
|
||||||
@jwt_required(optional=True)
|
|
||||||
def global_search():
|
|
||||||
"""
|
|
||||||
Global search across multiple entity types.
|
|
||||||
|
|
||||||
Returns combined results from:
|
def _classify_query(query):
|
||||||
- Machines (equipment and PCs)
|
"""Analyze the query string to determine its nature."""
|
||||||
- Applications
|
return {
|
||||||
- Knowledge Base articles
|
'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)),
|
||||||
- Printers (if available)
|
'is_sso': bool(re.match(r'^\d{9}$', query)),
|
||||||
|
'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)),
|
||||||
|
'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) else None,
|
||||||
|
'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)),
|
||||||
|
}
|
||||||
|
|
||||||
Results are sorted by relevance score.
|
|
||||||
"""
|
|
||||||
query = request.args.get('q', '').strip()
|
|
||||||
|
|
||||||
if not query or len(query) < 2:
|
def _get_asset_result(asset, query, relevance=None):
|
||||||
return success_response({
|
"""Build a search result dict from an Asset object."""
|
||||||
'results': [],
|
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
|
||||||
'query': query,
|
|
||||||
'message': 'Search query must be at least 2 characters'
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(query) > 200:
|
plugin_id = asset.assetid
|
||||||
return success_response({
|
if asset_type_name == 'equipment' and hasattr(asset, 'equipment') and asset.equipment:
|
||||||
'results': [],
|
plugin_id = asset.equipment.equipmentid
|
||||||
'query': query[:200],
|
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
|
||||||
'message': 'Search query too long'
|
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 = {
|
||||||
|
'equipment': f"/machines/{plugin_id}",
|
||||||
|
'computer': f"/pcs/{plugin_id}",
|
||||||
|
'network_device': f"/network/{plugin_id}",
|
||||||
|
'printer': f"/printers/{plugin_id}",
|
||||||
|
}
|
||||||
|
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
|
||||||
|
|
||||||
|
display_name = asset.display_name
|
||||||
|
subtitle = None
|
||||||
|
if asset.name and asset.assetnumber != asset.name:
|
||||||
|
subtitle = asset.assetnumber
|
||||||
|
|
||||||
|
location_name = asset.location.locationname if asset.location else None
|
||||||
|
|
||||||
|
if relevance is None:
|
||||||
|
relevance = 15
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': asset_type_name,
|
||||||
|
'id': plugin_id,
|
||||||
|
'title': display_name,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
'location': location_name,
|
||||||
|
'url': url,
|
||||||
|
'relevance': relevance
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _search_applications(query, search_term):
|
||||||
|
"""Search Applications by name and description."""
|
||||||
results = []
|
results = []
|
||||||
search_term = f'%{query}%'
|
|
||||||
|
|
||||||
# NOTE: Legacy Machine search is disabled - all data is now in the Asset table
|
|
||||||
# The Asset search below handles equipment, computers, network devices, and printers
|
|
||||||
# with proper plugin-specific IDs for correct routing
|
|
||||||
|
|
||||||
# Search Applications
|
|
||||||
try:
|
try:
|
||||||
apps = Application.query.filter(
|
apps = Application.query.filter(
|
||||||
Application.isactive == True,
|
Application.isactive == True,
|
||||||
@@ -76,10 +111,13 @@ def global_search():
|
|||||||
'relevance': relevance
|
'relevance': relevance
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
logger.error(f"Application search failed: {e}")
|
||||||
logging.error(f"Application search failed: {e}")
|
return results
|
||||||
|
|
||||||
# Search Knowledge Base
|
|
||||||
|
def _search_knowledgebase(query, search_term):
|
||||||
|
"""Search Knowledge Base by description and keywords."""
|
||||||
|
results = []
|
||||||
try:
|
try:
|
||||||
kb_articles = KnowledgeBase.query.filter(
|
kb_articles = KnowledgeBase.query.filter(
|
||||||
KnowledgeBase.isactive == True,
|
KnowledgeBase.isactive == True,
|
||||||
@@ -90,7 +128,6 @@ def global_search():
|
|||||||
).limit(20).all()
|
).limit(20).all()
|
||||||
|
|
||||||
for kb in kb_articles:
|
for kb in kb_articles:
|
||||||
# Weight by clicks and keyword match
|
|
||||||
relevance = 10 + (kb.clicks or 0) * 0.1
|
relevance = 10 + (kb.clicks or 0) * 0.1
|
||||||
if kb.keywords and query.lower() in kb.keywords.lower():
|
if kb.keywords and query.lower() in kb.keywords.lower():
|
||||||
relevance += 15
|
relevance += 15
|
||||||
@@ -105,13 +142,13 @@ def global_search():
|
|||||||
'relevance': relevance
|
'relevance': relevance
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
logger.error(f"KnowledgeBase search failed: {e}")
|
||||||
logging.error(f"KnowledgeBase search failed: {e}")
|
return results
|
||||||
|
|
||||||
# NOTE: Legacy Printer search removed - printers are now in the unified Asset table
|
|
||||||
# The Asset search below handles printers with correct plugin-specific IDs
|
|
||||||
|
|
||||||
# Search Employees (separate database)
|
def _search_employees(query, search_term):
|
||||||
|
"""Search Employees in separate wjf_employees database."""
|
||||||
|
results = []
|
||||||
try:
|
try:
|
||||||
import pymysql
|
import pymysql
|
||||||
emp_conn = pymysql.connect(
|
emp_conn = pymysql.connect(
|
||||||
@@ -140,7 +177,6 @@ def global_search():
|
|||||||
full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
|
full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
|
||||||
sso_str = str(emp['SSO'])
|
sso_str = str(emp['SSO'])
|
||||||
|
|
||||||
# Calculate relevance
|
|
||||||
relevance = 20
|
relevance = 20
|
||||||
if query == sso_str:
|
if query == sso_str:
|
||||||
relevance = 100
|
relevance = 100
|
||||||
@@ -158,12 +194,18 @@ def global_search():
|
|||||||
'relevance': relevance
|
'relevance': relevance
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
logger.error(f"Employee search failed: {e}")
|
||||||
logging.error(f"Employee search failed: {e}")
|
return results
|
||||||
|
|
||||||
# Search unified Assets table
|
|
||||||
|
def _search_assets(query, search_term):
|
||||||
|
"""Search unified Assets table by number, name, serial, notes."""
|
||||||
|
results = []
|
||||||
try:
|
try:
|
||||||
assets = Asset.query.join(AssetType).filter(
|
assets = Asset.query.join(AssetType).options(
|
||||||
|
joinedload(Asset.assettype),
|
||||||
|
joinedload(Asset.location),
|
||||||
|
).filter(
|
||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
db.or_(
|
db.or_(
|
||||||
Asset.assetnumber.ilike(search_term),
|
Asset.assetnumber.ilike(search_term),
|
||||||
@@ -171,10 +213,9 @@ def global_search():
|
|||||||
Asset.serialnumber.ilike(search_term),
|
Asset.serialnumber.ilike(search_term),
|
||||||
Asset.notes.ilike(search_term)
|
Asset.notes.ilike(search_term)
|
||||||
)
|
)
|
||||||
).limit(10).all()
|
).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
# Calculate relevance
|
|
||||||
relevance = 15
|
relevance = 15
|
||||||
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
|
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
|
||||||
relevance = 100
|
relevance = 100
|
||||||
@@ -185,48 +226,467 @@ def global_search():
|
|||||||
elif asset.name and query.lower() in asset.name.lower():
|
elif asset.name and query.lower() in asset.name.lower():
|
||||||
relevance = 50
|
relevance = 50
|
||||||
|
|
||||||
# Determine URL and type based on asset type
|
results.append(_get_asset_result(asset, query, relevance))
|
||||||
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
|
except Exception as e:
|
||||||
|
logger.error(f"Asset search failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
# 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 = {
|
def _search_by_ip(query, search_term):
|
||||||
'equipment': f"/machines/{plugin_id}",
|
"""Search Communications table for IP address matches."""
|
||||||
'computer': f"/pcs/{plugin_id}",
|
results = []
|
||||||
'network_device': f"/network/{plugin_id}",
|
try:
|
||||||
'printer': f"/printers/{plugin_id}",
|
comms = Communication.query.filter(
|
||||||
}
|
Communication.ipaddress.ilike(search_term)
|
||||||
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
|
).options(
|
||||||
|
joinedload(Communication.asset).joinedload(Asset.assettype),
|
||||||
|
joinedload(Communication.asset).joinedload(Asset.location),
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
display_name = asset.display_name
|
seen_assets = set()
|
||||||
subtitle = None
|
for comm in comms:
|
||||||
if asset.name and asset.assetnumber != asset.name:
|
asset = comm.asset
|
||||||
subtitle = asset.assetnumber
|
if not asset or not asset.isactive or asset.assetid in seen_assets:
|
||||||
|
continue
|
||||||
|
seen_assets.add(asset.assetid)
|
||||||
|
|
||||||
# Get location name
|
relevance = 80 if query == comm.ipaddress else 40
|
||||||
location_name = asset.location.locationname if asset.location else None
|
result = _get_asset_result(asset, query, relevance)
|
||||||
|
result['subtitle'] = comm.ipaddress
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"IP search failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_subnets(query):
|
||||||
|
"""Find which subnet an IP address belongs to."""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
from plugins.network.models import Subnet
|
||||||
|
ip_obj = ipaddress.ip_address(query)
|
||||||
|
subnets = Subnet.query.filter(Subnet.isactive == True).all()
|
||||||
|
for subnet in subnets:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(subnet.cidr, strict=False)
|
||||||
|
if ip_obj in network:
|
||||||
|
results.append({
|
||||||
|
'type': 'subnet',
|
||||||
|
'id': subnet.subnetid,
|
||||||
|
'title': f'{subnet.name} ({subnet.cidr})',
|
||||||
|
'subtitle': subnet.description or subnet.subnettype,
|
||||||
|
'url': f'/network',
|
||||||
|
'relevance': 70
|
||||||
|
})
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Subnet search failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_hostnames(query, search_term):
|
||||||
|
"""Search hostname fields across Computer, Printer, NetworkDevice."""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Search Computers
|
||||||
|
try:
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
computers = Computer.query.filter(
|
||||||
|
Computer.hostname.ilike(search_term)
|
||||||
|
).options(
|
||||||
|
joinedload(Computer.asset).joinedload(Asset.assettype),
|
||||||
|
joinedload(Computer.asset).joinedload(Asset.location),
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for comp in computers:
|
||||||
|
if comp.asset and comp.asset.isactive:
|
||||||
|
relevance = 85 if query.lower() == (comp.hostname or '').lower() else 40
|
||||||
|
result = _get_asset_result(comp.asset, query, relevance)
|
||||||
|
result['subtitle'] = comp.hostname
|
||||||
|
results.append(result)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Computer hostname search failed: {e}")
|
||||||
|
|
||||||
|
# Search Printers
|
||||||
|
try:
|
||||||
|
from plugins.printers.models import Printer
|
||||||
|
printers = Printer.query.filter(
|
||||||
|
db.or_(
|
||||||
|
Printer.hostname.ilike(search_term),
|
||||||
|
Printer.sharename.ilike(search_term),
|
||||||
|
Printer.windowsname.ilike(search_term),
|
||||||
|
)
|
||||||
|
).options(
|
||||||
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
||||||
|
joinedload(Printer.asset).joinedload(Asset.location),
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for printer in printers:
|
||||||
|
if printer.asset and printer.asset.isactive:
|
||||||
|
match_field = printer.hostname or printer.sharename or ''
|
||||||
|
relevance = 85 if query.lower() == match_field.lower() else 40
|
||||||
|
result = _get_asset_result(printer.asset, query, relevance)
|
||||||
|
result['subtitle'] = printer.hostname or printer.sharename
|
||||||
|
results.append(result)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Printer hostname search failed: {e}")
|
||||||
|
|
||||||
|
# Search Network Devices
|
||||||
|
try:
|
||||||
|
from plugins.network.models import NetworkDevice
|
||||||
|
devices = NetworkDevice.query.filter(
|
||||||
|
NetworkDevice.hostname.ilike(search_term)
|
||||||
|
).options(
|
||||||
|
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
||||||
|
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
if device.asset and device.asset.isactive:
|
||||||
|
relevance = 85 if query.lower() == (device.hostname or '').lower() else 40
|
||||||
|
result = _get_asset_result(device.asset, query, relevance)
|
||||||
|
result['subtitle'] = device.hostname
|
||||||
|
results.append(result)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Network device hostname search failed: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_notifications(query, search_term):
|
||||||
|
"""Search notifications with time-weighted relevance."""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
from plugins.notifications.models import Notification
|
||||||
|
|
||||||
|
notifications = Notification.query.options(
|
||||||
|
joinedload(Notification.notificationtype)
|
||||||
|
).filter(
|
||||||
|
db.or_(
|
||||||
|
Notification.notification.ilike(search_term),
|
||||||
|
Notification.ticketnumber.ilike(search_term)
|
||||||
|
)
|
||||||
|
).order_by(Notification.starttime.desc()).limit(15).all()
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
for notif in notifications:
|
||||||
|
base_relevance = 20
|
||||||
|
if notif.ticketnumber and query.lower() == notif.ticketnumber.lower():
|
||||||
|
base_relevance = 85
|
||||||
|
|
||||||
|
# Time-weighted relevance
|
||||||
|
if notif.is_current:
|
||||||
|
base_relevance *= 3
|
||||||
|
elif notif.starttime and notif.starttime > now:
|
||||||
|
base_relevance *= 2
|
||||||
|
elif notif.endtime and (now - notif.endtime).days < 7:
|
||||||
|
base_relevance = int(base_relevance * 1.5)
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'type': asset_type_name,
|
'type': 'notification',
|
||||||
'id': plugin_id,
|
'id': notif.notificationid,
|
||||||
'title': display_name,
|
'title': notif.title,
|
||||||
'subtitle': subtitle,
|
'subtitle': notif.notificationtype.typename if notif.notificationtype else None,
|
||||||
'location': location_name,
|
'url': f'/notifications',
|
||||||
'url': url,
|
'relevance': min(int(base_relevance), 100),
|
||||||
'relevance': relevance
|
'ticketnumber': notif.ticketnumber,
|
||||||
|
'iscurrent': notif.is_current
|
||||||
})
|
})
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
logger.error(f"Notification search failed: {e}")
|
||||||
logging.error(f"Asset search failed: {e}")
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_vendor_model_type(query, search_term):
|
||||||
|
"""Search assets by vendor name, model name, or equipment/device type name."""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Equipment: vendor, model, equipmenttype
|
||||||
|
try:
|
||||||
|
from plugins.equipment.models import Equipment, EquipmentType
|
||||||
|
equipment_assets = db.session.query(Asset).join(
|
||||||
|
Equipment, Equipment.assetid == Asset.assetid
|
||||||
|
).outerjoin(
|
||||||
|
Vendor, Equipment.vendorid == Vendor.vendorid
|
||||||
|
).outerjoin(
|
||||||
|
Model, Equipment.modelnumberid == Model.modelnumberid
|
||||||
|
).outerjoin(
|
||||||
|
EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
|
||||||
|
).options(
|
||||||
|
joinedload(Asset.assettype),
|
||||||
|
joinedload(Asset.location),
|
||||||
|
).filter(
|
||||||
|
Asset.isactive == True,
|
||||||
|
db.or_(
|
||||||
|
Vendor.vendor.ilike(search_term),
|
||||||
|
Model.modelnumber.ilike(search_term),
|
||||||
|
EquipmentType.equipmenttype.ilike(search_term)
|
||||||
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for asset in equipment_assets:
|
||||||
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Equipment vendor/model/type search failed: {e}")
|
||||||
|
|
||||||
|
# Printers: vendor, model, printertype
|
||||||
|
try:
|
||||||
|
from plugins.printers.models import Printer, PrinterType
|
||||||
|
printer_assets = db.session.query(Asset).join(
|
||||||
|
Printer, Printer.assetid == Asset.assetid
|
||||||
|
).outerjoin(
|
||||||
|
Vendor, Printer.vendorid == Vendor.vendorid
|
||||||
|
).outerjoin(
|
||||||
|
Model, Printer.modelnumberid == Model.modelnumberid
|
||||||
|
).outerjoin(
|
||||||
|
PrinterType, Printer.printertypeid == PrinterType.printertypeid
|
||||||
|
).options(
|
||||||
|
joinedload(Asset.assettype),
|
||||||
|
joinedload(Asset.location),
|
||||||
|
).filter(
|
||||||
|
Asset.isactive == True,
|
||||||
|
db.or_(
|
||||||
|
Vendor.vendor.ilike(search_term),
|
||||||
|
Model.modelnumber.ilike(search_term),
|
||||||
|
PrinterType.printertype.ilike(search_term)
|
||||||
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for asset in printer_assets:
|
||||||
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Printer vendor/model/type search failed: {e}")
|
||||||
|
|
||||||
|
# Network Devices: vendor, networkdevicetype
|
||||||
|
try:
|
||||||
|
from plugins.network.models import NetworkDevice, NetworkDeviceType
|
||||||
|
netdev_assets = db.session.query(Asset).join(
|
||||||
|
NetworkDevice, NetworkDevice.assetid == Asset.assetid
|
||||||
|
).outerjoin(
|
||||||
|
Vendor, NetworkDevice.vendorid == Vendor.vendorid
|
||||||
|
).outerjoin(
|
||||||
|
NetworkDeviceType, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
|
||||||
|
).options(
|
||||||
|
joinedload(Asset.assettype),
|
||||||
|
joinedload(Asset.location),
|
||||||
|
).filter(
|
||||||
|
Asset.isactive == True,
|
||||||
|
db.or_(
|
||||||
|
Vendor.vendor.ilike(search_term),
|
||||||
|
NetworkDeviceType.networkdevicetype.ilike(search_term)
|
||||||
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for asset in netdev_assets:
|
||||||
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Network device vendor/type search failed: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _check_smart_redirect(query, classification):
|
||||||
|
"""Check if query exactly matches a single entity for smart redirect."""
|
||||||
|
# Exact SSO match
|
||||||
|
if classification['is_sso']:
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
emp_conn = pymysql.connect(
|
||||||
|
host='localhost',
|
||||||
|
user='root',
|
||||||
|
password='rootpassword',
|
||||||
|
database='wjf_employees',
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
with emp_conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'SELECT SSO, First_Name, Last_Name FROM employees WHERE SSO = %s LIMIT 1',
|
||||||
|
(query,)
|
||||||
|
)
|
||||||
|
emp = cur.fetchone()
|
||||||
|
emp_conn.close()
|
||||||
|
if emp:
|
||||||
|
name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
|
||||||
|
return {
|
||||||
|
'type': 'employee',
|
||||||
|
'url': f"/employees/{emp['SSO']}",
|
||||||
|
'label': name
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Exact asset number match
|
||||||
|
try:
|
||||||
|
asset = Asset.query.options(
|
||||||
|
joinedload(Asset.assettype),
|
||||||
|
).filter(
|
||||||
|
Asset.assetnumber == query,
|
||||||
|
Asset.isactive == True
|
||||||
|
).first()
|
||||||
|
if asset:
|
||||||
|
result = _get_asset_result(asset, query)
|
||||||
|
return {
|
||||||
|
'type': result['type'],
|
||||||
|
'url': result['url'],
|
||||||
|
'label': asset.display_name
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Exact printer CSF/share name
|
||||||
|
try:
|
||||||
|
from plugins.printers.models import Printer
|
||||||
|
printer = Printer.query.options(
|
||||||
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
||||||
|
).filter(
|
||||||
|
db.or_(
|
||||||
|
Printer.sharename == query,
|
||||||
|
Printer.windowsname == query
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if printer and printer.asset and printer.asset.isactive:
|
||||||
|
return {
|
||||||
|
'type': 'printer',
|
||||||
|
'url': f"/printers/{printer.printerid}",
|
||||||
|
'label': printer.sharename or printer.asset.display_name
|
||||||
|
}
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Exact hostname match (FQDN or bare hostname)
|
||||||
|
hostname_plugins = []
|
||||||
|
try:
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
hostname_plugins.append(('computer', Computer, 'computerid', '/pcs'))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from plugins.printers.models import Printer
|
||||||
|
hostname_plugins.append(('printer', Printer, 'printerid', '/printers'))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from plugins.network.models import NetworkDevice
|
||||||
|
hostname_plugins.append(('network_device', NetworkDevice, 'networkdeviceid', '/network'))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for type_name, PluginModel, id_field, url_prefix in hostname_plugins:
|
||||||
|
try:
|
||||||
|
device = PluginModel.query.options(
|
||||||
|
joinedload(PluginModel.asset)
|
||||||
|
).filter(
|
||||||
|
PluginModel.hostname == query
|
||||||
|
).first()
|
||||||
|
if device and device.asset and device.asset.isactive:
|
||||||
|
return {
|
||||||
|
'type': type_name,
|
||||||
|
'url': f"{url_prefix}/{getattr(device, id_field)}",
|
||||||
|
'label': device.hostname
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Exact IP match
|
||||||
|
if classification['is_ip']:
|
||||||
|
try:
|
||||||
|
comm = Communication.query.options(
|
||||||
|
joinedload(Communication.asset).joinedload(Asset.assettype)
|
||||||
|
).filter(
|
||||||
|
Communication.ipaddress == query
|
||||||
|
).first()
|
||||||
|
if comm and comm.asset and comm.asset.isactive:
|
||||||
|
result = _get_asset_result(comm.asset, query)
|
||||||
|
return {
|
||||||
|
'type': result['type'],
|
||||||
|
'url': result['url'],
|
||||||
|
'label': f"{comm.asset.display_name} ({comm.ipaddress})"
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def global_search():
|
||||||
|
"""
|
||||||
|
Global search across multiple entity types.
|
||||||
|
|
||||||
|
Returns combined results from assets, applications, knowledge base,
|
||||||
|
employees, notifications, IP addresses, hostnames, and vendor/model/type.
|
||||||
|
Supports smart redirects and ServiceNOW ticket detection.
|
||||||
|
"""
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return success_response({
|
||||||
|
'results': [],
|
||||||
|
'query': query,
|
||||||
|
'message': 'Search query must be at least 2 characters'
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(query) > 200:
|
||||||
|
return success_response({
|
||||||
|
'results': [],
|
||||||
|
'query': query[:200],
|
||||||
|
'message': 'Search query too long'
|
||||||
|
})
|
||||||
|
|
||||||
|
classification = _classify_query(query)
|
||||||
|
|
||||||
|
# ServiceNOW prefix detection - return redirect immediately
|
||||||
|
if classification['is_servicenow']:
|
||||||
|
from urllib.parse import quote
|
||||||
|
servicenow_url = SERVICENOW_URL.format(ticket=quote(query))
|
||||||
|
return success_response({
|
||||||
|
'results': [],
|
||||||
|
'query': query,
|
||||||
|
'total': 0,
|
||||||
|
'counts': {},
|
||||||
|
'redirect': {
|
||||||
|
'type': 'servicenow',
|
||||||
|
'url': servicenow_url,
|
||||||
|
'label': f'Open {query} in ServiceNOW'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
search_term = f'%{query}%'
|
||||||
|
|
||||||
|
# Run all search domains
|
||||||
|
results.extend(_search_applications(query, search_term))
|
||||||
|
results.extend(_search_knowledgebase(query, search_term))
|
||||||
|
results.extend(_search_employees(query, search_term))
|
||||||
|
results.extend(_search_assets(query, search_term))
|
||||||
|
results.extend(_search_notifications(query, search_term))
|
||||||
|
results.extend(_search_hostnames(query, search_term))
|
||||||
|
results.extend(_search_vendor_model_type(query, search_term))
|
||||||
|
|
||||||
|
# IP-specific searches
|
||||||
|
if classification['is_ip']:
|
||||||
|
results.extend(_search_by_ip(query, search_term))
|
||||||
|
results.extend(_search_subnets(query))
|
||||||
|
|
||||||
# Sort by relevance (highest first)
|
# Sort by relevance (highest first)
|
||||||
results.sort(key=lambda x: x['relevance'], reverse=True)
|
results.sort(key=lambda x: x['relevance'], reverse=True)
|
||||||
@@ -240,11 +700,28 @@ def global_search():
|
|||||||
seen_ids[key] = True
|
seen_ids[key] = True
|
||||||
unique_results.append(r)
|
unique_results.append(r)
|
||||||
|
|
||||||
# Limit total results
|
# Compute type counts before truncation
|
||||||
unique_results = unique_results[:30]
|
type_counts = {}
|
||||||
|
for r in unique_results:
|
||||||
|
t = r['type']
|
||||||
|
type_counts[t] = type_counts.get(t, 0) + 1
|
||||||
|
|
||||||
return success_response({
|
total_all = len(unique_results)
|
||||||
|
|
||||||
|
# Limit total results
|
||||||
|
unique_results = unique_results[:50]
|
||||||
|
|
||||||
|
# Check for smart redirect
|
||||||
|
response_data = {
|
||||||
'results': unique_results,
|
'results': unique_results,
|
||||||
'query': query,
|
'query': query,
|
||||||
'total': len(unique_results)
|
'total': len(unique_results),
|
||||||
})
|
'total_all': total_all,
|
||||||
|
'counts': type_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect = _check_smart_redirect(query, classification)
|
||||||
|
if redirect:
|
||||||
|
response_data['redirect'] = redirect
|
||||||
|
|
||||||
|
return success_response(response_data)
|
||||||
|
|||||||
Reference in New Issue
Block a user