Files
shopdb-flask/frontend/src/views/SearchResults.vue
cproudlock e60d6b74d8 Search: a domain that FAILED no longer looks like one that found nothing
Every domain catches broadly and returns no rows so one broken table cannot take
the whole search box down. That part is right. On its own, though, it makes a
DOWN domain indistinguishable from an EMPTY one: the technician sees no results,
is offered a spelling correction, and concludes the record does not exist. It
does - the search just could not look.

Failed domains are now named in the response and stated above the results.

The suggestion is deliberately still offered on a degraded run. Suppressing it
was tried first and is worse: a chronically unreachable domain, an external HR
directory say, would silently disable spelling help for every search for ever
with nothing saying why. Naming the failure is the honest fix; withholding a
second useful signal is not.

Guides search also moves to the published revision. It correctly limited itself
to published GUIDES, but matched and displayed title/summary/tags/blocksjson
from the Guide row - which is the WORKING copy. A published guide with
unpublished edits matched on wording that only exists in the draft, and the
reader opened it to find the word absent.
2026-08-26 15:05:54 -04:00

543 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>
</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>