Naming convention (LOCKED): rename the ManifestEditor simulate state sim -> simulateInputs / simResult -> simulateResult (+ .sim-result CSS class) - 'sim' was banned standalone shorthand. Rename AssetRelationships props assetId -> assetid and machineNumber -> machinenumber so a prop holding a DB field value mirrors it verbatim; updated the five detail-page call sites (:assetid=). CSS variables: SearchResults per-domain badge palette moved into CSS variables on the container; the duplicated prefers-color-scheme dark block collapses to a single set of variable overrides instead of restating all ten selectors. frontend build green; vitest 49 pass; naming green; search badges + detail relationships verified rendering with no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
480 lines
13 KiB
Vue
480 lines
13 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">
|
|
<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 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'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
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: 'Machine',
|
|
pc: 'PC',
|
|
computer: 'PC',
|
|
application: 'App',
|
|
knowledgebase: 'KB',
|
|
printer: 'Printer',
|
|
network_device: 'Network',
|
|
measuring_tool: 'Measuring Tool',
|
|
employee: 'Employee',
|
|
notification: 'Notice',
|
|
subnet: 'Subnet'
|
|
}
|
|
|
|
const filterTypeMap = {
|
|
all: null,
|
|
machines: ['machine'],
|
|
computers: ['computer'],
|
|
printers: ['printer'],
|
|
network: ['network_device', 'subnet'],
|
|
measuringtools: ['measuring_tool'],
|
|
applications: ['application'],
|
|
knowledgebase: ['knowledgebase'],
|
|
notifications: ['notification'],
|
|
employees: ['employee']
|
|
}
|
|
|
|
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: '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 = []
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
activeFilter.value = 'all'
|
|
|
|
try {
|
|
const response = await searchApi.search(q)
|
|
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)
|
|
results.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function performSearch() {
|
|
if (searchInput.value.trim()) {
|
|
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
|
}
|
|
}
|
|
|
|
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-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.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;
|
|
}
|
|
}
|
|
</style>
|