Correction only helped on the global search page, and people search from the list they are already on. Extending it turned out to be a shape question rather than a volume one: thirty routes take a search parameter across a dozen files, so carrying a suggestion in each of their responses is a large change today and one more thing every future plugin author has to remember. So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page can call after rendering no rows. A page that has not adopted it shows nothing, which is exactly what it showed before - nothing breaks by omission. The plumbing lives in useListQuery, which already owned the search term, so a list needs three lines: take `suggestion` and `reportCount` from the composable, call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the empty state it already has. A list that never calls reportCount never offers a suggestion. Wired: global search, machines, printers, PCs, network devices, measuring tools, knowledge base, vendors. NOT wired, deliberately: the type and reference lists (machine types, PC types, VLANs, subnets, operating systems and the rest), which are small controlled vocabularies nobody typo-searches, and USB, whose empty state has a different shape and wants doing by hand rather than by pattern. TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a suggestion by calling setSearch alone updates the box and the URL and does NOT reload the list. setSearch only syncs the URL, and the watcher that would reload is suppressed because search.value already holds the new term - the same trap the global search page documents in performSearch. Each page calls its own load function directly. The composable guards a stale answer arriving after a newer search was typed, never offers back the word that was typed, and swallows its own errors: a search that found nothing is already the answer, and failing to improve on it is not worth an error in front of anyone. The route-parity gate caught the new endpoint being served without an entry in docs/api-inventory.json, which is hand-written on purpose; added, and the spec regenerated from it (283 paths, 418 operations). That regeneration also carries the openapi version to 0.12.0, left over from the release.
224 lines
6.1 KiB
Vue
224 lines
6.1 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Computers</h2>
|
|
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
|
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search computers..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Asset Tag</th>
|
|
<th>Hostname</th>
|
|
<th>Serial Number</th>
|
|
<th>PC Type</th>
|
|
<th>Remote Access</th>
|
|
<th>Status</th>
|
|
<th>Location</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
|
|
<td>{{ item.assetnumber }}</td>
|
|
<td>{{ item.computer?.hostname || '-' }}</td>
|
|
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
|
<td>{{ item.computer?.computertypename || '-' }}</td>
|
|
<td class="features">
|
|
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
|
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
|
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
|
</template>
|
|
<span v-if="!(item.accessmethods || []).length">-</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge" :style="colorStyle(item.statuscolor)">
|
|
{{ item.statusname || 'Unknown' }}
|
|
</span>
|
|
</td>
|
|
<td>{{ item.locationname || '-' }}</td>
|
|
<td class="actions" @click.stop>
|
|
<router-link
|
|
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
|
class="btn btn-secondary btn-sm"
|
|
>
|
|
View
|
|
</router-link>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="computers.length === 0">
|
|
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
|
No computers found
|
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { computersApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { colorStyle } from '@/utils/colorStyle'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
|
|
|
const computers = ref([])
|
|
const loading = ref(true)
|
|
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
|
|
|
|
// Take the offered spelling. setSearch only syncs the URL and the watcher
|
|
// that reloads is suppressed because search.value already holds the new
|
|
// term, so load directly.
|
|
function applySuggestion(word) {
|
|
search.value = word
|
|
setSearch(word)
|
|
loadComputers()
|
|
}
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadComputers()
|
|
})
|
|
|
|
async function loadComputers() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value
|
|
}
|
|
if (search.value) params.search = search.value
|
|
|
|
const response = await computersApi.list(params)
|
|
computers.value = response.data.data || []
|
|
reportCount(computers.value.length)
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading computers:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadComputers()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadComputers()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadComputers()
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.access-link {
|
|
display: inline-block;
|
|
padding: 2px 10px;
|
|
margin: 0 4px 3px 0;
|
|
border-radius: 12px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
.access-link:hover {
|
|
background: var(--primary-dark);
|
|
text-decoration: none;
|
|
}
|
|
.access-link.disabled {
|
|
background: var(--secondary);
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.mono {
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
|
offsets the row. lay tags out inline instead. */
|
|
.features {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.feature-tag {
|
|
display: inline-block;
|
|
margin-right: 0.375rem;
|
|
padding: 0.3rem 0.625rem;
|
|
font-size: 0.875rem;
|
|
border-radius: 5px;
|
|
background: var(--bg);
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.feature-tag:last-child {
|
|
margin-right: 0;
|
|
}
|
|
|
|
/* the global .actions rule is inline-flex, which also breaks table-cell
|
|
alignment when applied straight on a <td>; pin it back to a cell here. */
|
|
td.actions {
|
|
display: table-cell;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.feature-tag.active {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.feature-tag.active {
|
|
background: #1e3a5f;
|
|
color: #60a5fa;
|
|
}
|
|
}
|
|
</style>
|