Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,479 @@
<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>