Files
shopdb-flask/plugins/computers/frontend/views/PCsList.vue
cproudlock f4d3827214 Record how a PC is reached, and stop pages implying they can do things
Three findings, all about a screen or a log saying something that is not so.

Editing a PC wrote an audit entry for its named fields and nothing for the two
changes that matter most when somebody investigates later: the primary IP
address, which is how the machine is found, and the remote-access protocols and
ports, which is how somebody connects to it. Both are on the same form, in the
same request, and both were invisible afterwards - a PC that moved address had
no history of moving, and a PC that gained a VNC listener had no record of
gaining one. Both are recorded now, and re-sending the same value still records
nothing, because an audit log full of non-events is one nobody reads.

While proving that, a silent discard turned up in the same lines: both the
create and the update path did `if ip_comtype:` around the IP write and did
nothing at all when the 'IP' communication type row was missing. Somebody typed
an address, saved, got a 200, and the address was gone. A missing reference row
is a seeding gap, not a reason to drop what was typed, so it is created on
demand.

The PCs list rendered a 403 as "No computers found", which tells a person
without the permission that the fleet is empty and sends them looking for the
data rather than for the permission. It now says which permission is missing.

The Site Policies and Configuration Policies pages rendered their empty table
and an Add button while the policy feature flag was off - every route answers
404 "Policy feature is not enabled", so the button could never work. Both pages
now say the feature is off and that the empty list is a consequence of that,
not of nothing being configured.

Nine tests. The audit entries verified by planting: remove either and two fail.

Scope: product
2026-09-03 16:56:22 -04:00

245 lines
7.3 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"
/>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadComputers" />
Show retired
</label>
</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);">
<!-- A 403 is not an empty result. Rendering "No computers
found" to somebody who simply may not see them tells
them the fleet is empty, and they go looking for the
data rather than for the permission. -->
<template v-if="denied">
You do not have permission to view computers. Ask an
administrator for the computers.view permission.
</template>
<template v-else>No computers found</template>
<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 showRetired = ref(false)
// Told apart from an empty list: see the table's empty row.
const denied = ref(false)
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
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
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)
denied.value = error?.response?.status === 403
} 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>