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:
cproudlock
2026-02-03 23:07:09 -05:00
parent c3ce69da12
commit c4bfdc2db2
5 changed files with 1018 additions and 172 deletions

View File

@@ -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 })
} }
} }

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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()

View File

@@ -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)