The last two items from the run. The PC detail page has always rendered a Business Unit row, and the API has always accepted the field. Only the form had no control, so it could be read and never set from the UI - a value that appeared the moment somebody set it through the API and could not be reached otherwise. The form has the picker now, loaded with listAll() rather than a capped page. Global search excluded inactive assets, so a retired record existed, was reachable by URL, and could not be found by anybody who did not already know where it was - including whoever wanted to restore it, which the retire dialog promises they can. Retired assets are searchable now. That is only safe with the ordering, so it is part of the change rather than a follow-up: somebody searching a machine number almost always wants the machine still on the floor, so a retired match drops 60 points of relevance, sorts below every live one, and carries a 'retired' flag the results page renders as a badge. Indistinguishable retired results would be worse than none. Four tests, verified by planting the isactive filter back: three fail. Scope: product
549 lines
16 KiB
Vue
549 lines
16 KiB
Vue
<template>
|
|
<div class="search-results">
|
|
<div class="page-header">
|
|
<h2>Search Results</h2>
|
|
<span v-if="results.length" class="results-count">
|
|
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
|
|
</span>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input
|
|
v-model="searchInput"
|
|
type="text"
|
|
class="form-control"
|
|
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">
|
|
<!--
|
|
A domain that could not be searched is stated plainly. Without this,
|
|
a broken table reads as "that record does not exist" - and the person
|
|
goes off believing the machine was never entered.
|
|
-->
|
|
<div v-if="degraded.length" class="search-degraded">
|
|
<strong>Some results may be missing.</strong>
|
|
Could not search: {{ degraded.join(', ') }}.
|
|
Anything in {{ degraded.length > 1 ? 'those areas' : 'that area' }}
|
|
will not appear below, even if it exists.
|
|
</div>
|
|
|
|
<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 }}"
|
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
|
</div>
|
|
|
|
<div v-else class="results-list">
|
|
<div
|
|
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">
|
|
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
|
|
{{ result.title }}
|
|
</router-link>
|
|
<a
|
|
v-else
|
|
href="#"
|
|
class="result-title"
|
|
@click.prevent="openKBArticle(result)"
|
|
>
|
|
{{ result.title }}
|
|
</a>
|
|
<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>
|
|
<!-- Retired assets are searchable on purpose (the retire
|
|
dialog promises they can be restored, and the search box
|
|
is where somebody looks for one). They rank below live
|
|
assets and say so, or a retired record looks like a
|
|
current one. -->
|
|
<span v-if="result.retired" class="badge badge-secondary">retired</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, knowledge base articles, IPs, and more.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { searchApi, knowledgebaseApi } from '../api'
|
|
import SearchSuggestion from '../components/SearchSuggestion.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const loading = ref(false)
|
|
const results = ref([])
|
|
const suggestion = ref(null)
|
|
const degraded = 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: 'Machine',
|
|
pc: 'PC',
|
|
computer: 'PC',
|
|
application: 'App',
|
|
knowledgebase: 'KB',
|
|
guide: 'Guide',
|
|
printer: 'Printer',
|
|
network_device: 'Network',
|
|
measuring_tool: 'Measuring Tool',
|
|
employee: 'Employee',
|
|
notification: 'Notice',
|
|
subnet: 'Subnet',
|
|
usb_device: 'USB',
|
|
printed_item: 'Printed Part'
|
|
}
|
|
|
|
const filterTypeMap = {
|
|
all: null,
|
|
machines: ['machine'],
|
|
computers: ['computer'],
|
|
printers: ['printer'],
|
|
network: ['network_device', 'subnet'],
|
|
measuringtools: ['measuring_tool'],
|
|
applications: ['application'],
|
|
knowledgebase: ['knowledgebase'],
|
|
guides: ['guide'],
|
|
notifications: ['notification'],
|
|
employees: ['employee'],
|
|
usb: ['usb_device'],
|
|
printedparts: ['printed_item']
|
|
}
|
|
|
|
const filterList = [
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'machines', label: 'Machines' },
|
|
{ key: 'computers', label: 'PCs' },
|
|
{ key: 'printers', label: 'Printers' },
|
|
{ key: 'network', label: 'Network' },
|
|
{ key: 'measuringtools', label: 'Measuring Tools' },
|
|
{ key: 'applications', label: 'Apps' },
|
|
{ key: 'knowledgebase', label: 'KB' },
|
|
{ key: 'guides', label: 'Guides' },
|
|
{ key: 'notifications', label: 'Notices' },
|
|
{ key: 'employees', label: 'Employees' },
|
|
{ key: 'usb', label: 'USB' },
|
|
{ key: 'printedparts', label: 'Printed Parts' }
|
|
]
|
|
|
|
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 = []
|
|
suggestion.value = null
|
|
degraded.value = []
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
activeFilter.value = 'all'
|
|
|
|
try {
|
|
const response = await searchApi.search(q)
|
|
const data = response.data.data
|
|
// The global endpoint carries the suggestion with the results, so no second
|
|
// call is needed here. A list page that has no such key uses the
|
|
// useSearchSuggestion composable against /search/suggest instead.
|
|
suggestion.value = data?.suggestion || null
|
|
degraded.value = data?.degraded || []
|
|
|
|
// 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)
|
|
results.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Take the offered spelling: put it in the box and search it, so the person
|
|
// sees what was searched rather than results for a word they did not type.
|
|
function applySuggestion(word) {
|
|
searchInput.value = word
|
|
suggestion.value = null
|
|
performSearch()
|
|
}
|
|
|
|
function performSearch() {
|
|
const term = searchInput.value.trim()
|
|
if (!term) return
|
|
// Searching a term that is ALREADY in the URL is a duplicate navigation: the
|
|
// router aborts it, so the route watcher never fires and the button does
|
|
// nothing at all. Re-run the query directly in that case. The sidebar box
|
|
// never hits this, because it is always navigating from somewhere else -
|
|
// which is why the two search boxes appeared to behave differently.
|
|
if (term === route.query.q) {
|
|
search(term)
|
|
return
|
|
}
|
|
router.push({ path: '/search', query: { q: term } })
|
|
}
|
|
|
|
async function openKBArticle(result) {
|
|
try {
|
|
await knowledgebaseApi.trackClick(result.id)
|
|
if (result.linkurl) {
|
|
window.open(result.linkurl, '_blank')
|
|
} else {
|
|
router.push(result.url)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error tracking click:', error)
|
|
if (result.linkurl) {
|
|
window.open(result.linkurl, '_blank')
|
|
} else {
|
|
router.push(result.url)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
|
|
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>
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.page-header h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.results-count {
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.search-box {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.search-box input {
|
|
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);
|
|
padding: 2rem;
|
|
}
|
|
|
|
.results-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.result-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 0.3s ease;
|
|
}
|
|
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.result-item.highlighted {
|
|
background: rgba(65, 129, 255, 0.08);
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Per-domain badge palette. Values live in CSS variables on the container so
|
|
the dark theme overrides them in one place (below) instead of restating
|
|
every selector. Each badge rule just references its pair. */
|
|
.search-results {
|
|
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
|
|
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
|
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
|
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
|
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
|
|
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
|
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
|
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
|
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
|
|
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
|
|
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
|
|
}
|
|
|
|
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
|
|
.result-type.pc,
|
|
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
|
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
|
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
|
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
|
|
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
|
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
|
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
|
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
|
|
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
|
|
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
|
|
|
|
.result-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.result-title {
|
|
color: var(--link);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.result-title:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.result-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.result-subtitle {
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.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) {
|
|
.search-results {
|
|
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
|
|
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
|
|
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
|
|
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
|
|
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
|
|
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
|
|
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
|
|
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
|
|
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
|
|
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
|
}
|
|
}
|
|
|
|
/* Warning, not an error: results below are still usable, just incomplete. */
|
|
.search-degraded {
|
|
margin-bottom: 1rem;
|
|
padding: 0.7rem 0.9rem;
|
|
border: 1px solid var(--warning, #b26a00);
|
|
border-left-width: 4px;
|
|
border-radius: 4px;
|
|
background: var(--surface, transparent);
|
|
color: var(--text);
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|