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 => {
const token = localStorage.getItem('token')
if (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
})
@@ -306,6 +310,9 @@ export const printersApi = {
export const dashboardApi = {
summary() {
return api.get('/dashboard/summary')
},
navigation() {
return api.get('/dashboard/navigation')
}
}
@@ -444,8 +451,8 @@ export const applicationsApi = {
// Search API
export const searchApi = {
search(query) {
return api.get('/search', { params: { q: query } })
search(query, params = {}) {
return api.get('/search', { params: { q: query, ...params } })
}
}
@@ -639,6 +646,9 @@ export const reportsApi = {
},
assetInventory(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 pickedPosition = ref(null)
let pickerMarker = null
let markerLayer = null
let canvasRenderer = null
const filters = ref({
machinetype: '',
@@ -258,11 +260,15 @@ const visibleSubtypes = computed(() => {
function initMap() {
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, {
crs: L.CRS.Simple,
minZoom: -4,
maxZoom: 2,
attributionControl: false
attributionControl: false,
renderer: canvasRenderer
})
const blueprintUrl = props.theme === 'light'
@@ -397,21 +403,21 @@ function renderMarkers() {
detailRoute = getDetailRoute(item)
}
const icon = L.divIcon({
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
popupAnchor: [0, -6],
className: 'machine-marker'
// Use circleMarker instead of divIcon marker - renders on canvas
// for much better performance (no DOM element per marker)
const marker = L.circleMarker([leafletY, leafletX], {
radius: 6,
fillColor: color,
color: 'rgba(255,255,255,0.8)',
weight: 2,
fillOpacity: 1,
renderer: canvasRenderer
})
const marker = L.marker([leafletY, leafletX], { icon })
// Build tooltip content
let tooltipLines = [`<strong>${displayName}</strong>`]
if (props.assetTypeMode) {
// Asset mode tooltips
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
if (item.primaryip) {
@@ -421,7 +427,6 @@ function renderMarkers() {
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
}
} else {
// Legacy machine mode tooltips
const category = item.category?.toLowerCase() || ''
if (typeName && typeName.toLowerCase() !== 'locationonly') {
@@ -448,7 +453,6 @@ function renderMarkers() {
}
}
// Business unit
if (item.businessunit) {
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
}
@@ -561,7 +565,7 @@ function applyFilters() {
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)
}
watch(() => props.machines, () => {
if (map) renderMarkers()
}, { deep: true })
watch(() => props.machines, (newVal, oldVal) => {
if (map && newVal !== oldVal) renderMarkers()
})
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {

View File

@@ -3,7 +3,7 @@
<div class="page-header">
<h2>Search Results</h2>
<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>
</div>
@@ -12,25 +12,45 @@
v-model="searchInput"
type="text"
class="form-control"
placeholder="Search machines, applications, knowledge base..."
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
@keyup.enter="performSearch"
/>
<button class="btn btn-primary" @click="performSearch">Search</button>
</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 v-if="loading" class="loading">Searching...</div>
<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 }}"
</div>
<div v-else class="results-list">
<div
v-for="result in results"
v-for="result in filteredResults"
:key="`${result.type}-${result.id}`"
:id="`result-${result.type}-${result.id}`"
class="result-item"
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
>
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
<div class="result-content">
@@ -48,21 +68,30 @@
<div class="result-meta">
<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.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
</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>
</template>
<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>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
@@ -73,19 +102,68 @@ const loading = ref(false)
const results = ref([])
const query = 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 = {
machine: 'Equipment',
equipment: 'Equipment',
pc: 'PC',
computer: 'PC',
application: 'App',
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) {
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) {
if (!q || q.length < 2) {
results.value = []
@@ -93,9 +171,30 @@ async function search(q) {
}
loading.value = true
activeFilter.value = 'all'
try {
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
} catch (error) {
console.error('Search error:', error)
@@ -112,7 +211,6 @@ function performSearch() {
}
async function openKBArticle(result) {
// Track the click before opening
try {
await knowledgebaseApi.trackClick(result.id)
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(() => {
const q = route.query.q
const hl = route.query.highlight
if (hl) highlightId.value = hl
if (q) {
searchInput.value = q
search(q)
@@ -141,9 +262,15 @@ onMounted(() => {
watch(() => route.query.q, (newQ) => {
if (newQ) {
searchInput.value = newQ
const hl = route.query.highlight
if (hl) highlightId.value = hl
search(newQ)
}
})
watch(results, () => {
scrollToHighlight()
})
</script>
<style scoped>
@@ -159,7 +286,7 @@ watch(() => route.query.q, (newQ) => {
}
.results-count {
color: var(--text-light, #666);
color: var(--text-light);
font-size: 0.9rem;
}
@@ -173,9 +300,52 @@ watch(() => route.query.q, (newQ) => {
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 {
text-align: center;
color: var(--text-light, #666);
color: var(--text-light);
padding: 2rem;
}
@@ -189,29 +359,38 @@ watch(() => route.query.q, (newQ) => {
align-items: flex-start;
gap: 1rem;
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 {
border-bottom: none;
}
.result-type {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.25rem 0.5rem;
border-radius: 4px;
min-width: 70px;
text-align: center;
.result-item.highlighted {
background: rgba(65, 129, 255, 0.08);
border-left: 3px solid var(--primary);
}
.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;
color: #1565c0;
}
.result-type.pc {
.result-type.pc,
.result-type.computer {
background: #e8f5e9;
color: #2e7d32;
}
@@ -231,12 +410,33 @@ watch(() => route.query.q, (newQ) => {
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 {
flex: 1;
min-width: 0;
}
.result-title {
color: var(--primary, #1976d2);
color: var(--link);
text-decoration: none;
font-weight: 500;
}
@@ -247,21 +447,88 @@ watch(() => route.query.q, (newQ) => {
.result-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--text-light, #666);
font-size: 0.8rem;
color: var(--text-light);
}
.result-subtitle {
color: var(--text-light, #666);
}
.result-location {
color: var(--text-light, #666);
color: var(--text-light);
}
.result-location::before {
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>