Files
shopdb-flask/plugins/endpoints/frontend/views/EndpointsList.vue
cproudlock 0baed4cc80
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m20s
Close the gaps against the design: critical tile, filters, policy card, action placement
Four things the mockup showed and the first cut did not have.

FLEET
  - A 'with critical events' tile, from the stability route that existed and
    nothing called. Clicking it narrows to rows whose health is not ok - client
    side, because the health rollup is assembled per row after pagination, so
    it narrows the PAGE rather than the fleet. Worth knowing before the count
    is read as fleet-wide.
  - PC type and Location filters. The list route already accepted both; there
    was simply no control. Both use listAll, not list: perpage is clamped to
    100 server-side, so a long location list would lose its tail in a dropdown
    while still looking complete.
  - The machine cell shows number and name together where they differ, so
    '3105 Okuma' rather than a bare number.

PC DETAIL
  - A Policy card, contributed by the computers plugin. It matters because the
    effective policy already narrows the command allowlist, so without it the
    reason a control is unavailable on a given PC is written down nowhere.
    Provenance, so it sits in the reference column, not the triage band.

    Its route returns an EMPTY field list rather than the feature-gate 404 when
    policies are disabled. The panel contract hides a card with no fields, so
    it disappears on sites that have not enabled the feature - a 404 would have
    rendered a broken card on most of them. That was flagged in the design
    review as a snag; this is the fix.

  - Refresh Status and Restart Computer moved out of the page header into the
    triage band. They act on the MACHINE; Print Label, Edit, Retire and Back to
    List act on the RECORD, and mixing them put the most consequential button
    on the page - it halts whatever machine the PC serves - between printing a
    sticker and editing a form. In the band it sits beside the liveness chip
    and the machine it would take down.

    The band takes them through an 'actions' slot rather than knowing about PC
    verbs, so a measuring tool can put 'Calibrate' there without the core
    component learning anything about computers.

Full suite 2124 passed, vitest 260 passed, gates green, spec at 333 paths.
2026-08-28 23:00:25 -04:00

418 lines
14 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Fleet</h2>
</div>
<!-- Summary strip. Wired to dashboard routes that already existed and
nothing called; each tile is a filter shortcut, not just a number. -->
<div v-if="summary" class="fleet-tiles">
<button class="fleet-tile" :class="{ on: status === '' && !problemsonly }" @click="clearFilters">
<span class="n">{{ summary.total }}</span><span class="l">Endpoints</span>
</button>
<button class="fleet-tile ok" :class="{ on: status === 'reporting' }" @click="setStatus('reporting')">
<span class="n">{{ summary.reporting }}</span><span class="l">Reporting</span>
</button>
<button class="fleet-tile warn" :class="{ on: status === 'quiet' }" @click="setStatus('quiet')">
<span class="n">{{ summary.quiet }}</span><span class="l">Quiet over {{ summary.quiethours }}h</span>
</button>
<button class="fleet-tile bad" :class="{ on: status === 'never' }" @click="setStatus('never')">
<span class="n">{{ summary.never }}</span><span class="l">Never reported</span>
</button>
<!-- Crash, shutdown and corruption events in the stability window. The
route existed and nothing called it. -->
<button class="fleet-tile bad" :class="{ on: problemsonly }" @click="toggleProblems">
<span class="n">{{ criticalcount }}</span><span class="l">With critical events</span>
</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search hostname, asset or machine number..."
@input="debouncedSearch"
/>
<select v-model="status" class="form-control fleet-filter" @change="applyFilters">
<option value="">All statuses</option>
<option value="reporting">Reporting</option>
<option value="quiet">Quiet</option>
<option value="never">Never reported</option>
</select>
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
<option value="">All PC types</option>
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
{{ type.computertype }}
</option>
</select>
<select v-model="locationid" class="form-control fleet-filter" @change="applyFilters">
<option value="">All locations</option>
<option v-for="place in locations" :key="place.locationid" :value="place.locationid">
{{ place.locationname }}
</option>
</select>
<select v-model="sort" class="form-control fleet-filter" @change="applyFilters">
<option value="status">Sort: worst first</option>
<option value="hostname">Sort: hostname</option>
<option value="lastreported">Sort: last reported</option>
<option value="uptime">Sort: uptime</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="loadError" class="load-error">
{{ loadError }}
</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Hostname</th>
<th>Machine</th>
<th>PC Type</th>
<th>Health</th>
<th>Logged-in User</th>
<th>Uptime</th>
<th>Last Reported</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in endpoints"
:key="item.assetid"
class="clickable-row"
:class="{ disabled: !item.computerid }"
@click="openPc(item)"
>
<td>
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
</td>
<td>{{ item.hostname || '-' }}</td>
<td>
<!-- What stops if this PC is dead. On a shop floor that is
the first thing anyone needs to know. -->
<span v-if="!item.machines || !item.machines.length">-</span>
<span v-else>{{ item.machines.map(machineLabel).join(', ') }}</span>
</td>
<td>{{ item.computertype || '-' }}</td>
<td>
<span v-if="item.health && item.health.label"
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
<span v-else>-</span>
</td>
<td>{{ item.loggedinuser || '-' }}</td>
<td>{{ item.uptimedisplay || '-' }}</td>
<td>{{ formatRelative(item.lastreportedat) }}</td>
</tr>
<tr v-if="endpoints.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No endpoints found
</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 { useRouter } from 'vue-router'
import { endpointsApi, computersApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const router = useRouter()
const endpoints = ref([])
const loading = ref(true)
// A failed request used to leave endpoints as [] and render "No endpoints
// found", which is the same thing a healthy empty fleet shows. A trailing-slash
// redirect broke this list in production while the agent reported correctly all
// along, and the page said only that there was nothing to see. An error must
// look like an error.
const loadError = ref('')
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadEndpoints })
const totalPages = ref(1)
const perPage = ref(20)
const status = ref('')
// Worst first by default. Sorting by last-reported floats the healthiest
// endpoints to the top and buries the ones somebody needs to act on.
const sort = ref('status')
const summary = ref(null)
const criticalcount = ref(0)
const computertypeid = ref('')
const locationid = ref('')
const problemsonly = ref(false)
const computertypes = ref([])
const locations = ref([])
let searchTimeout = null
onMounted(() => {
loadEndpoints()
loadSummary()
loadFilterOptions()
})
async function loadFilterOptions() {
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
// location list would silently lose its tail in a dropdown.
try {
computertypes.value = await computersApi.types.listAll()
} catch (error) {
computertypes.value = []
}
try {
locations.value = await locationsApi.listAll()
} catch (error) {
locations.value = []
}
}
async function loadSummary() {
try {
const response = await endpointsApi.dashboardSummary()
summary.value = response.data.data || null
const stability = await endpointsApi.dashboardStability()
criticalcount.value = (stability.data.data || []).length
} catch (error) {
// The strip is a convenience, not the page. If it fails the table still
// works, so say nothing rather than blocking on it.
summary.value = null
}
}
async function loadEndpoints() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (status.value) params.status = status.value
if (sort.value) params.sort = sort.value
if (computertypeid.value) params.computertypeid = computertypeid.value
if (locationid.value) params.locationid = locationid.value
loadError.value = ''
const response = await endpointsApi.list(params)
const rows = response.data.data || []
endpoints.value = problemsonly.value
? rows.filter(row => row.health && row.health.level !== 'ok')
: rows
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading endpoints:', error)
endpoints.value = []
// Name the status. 403 and 404 are the two that get misread as "no
// endpoints": the first means the role lacks endpoints.view, the second
// that the request never reached the route at all.
const status = error.response?.status
loadError.value = status
? `Could not load endpoints (HTTP ${status}). This is a failed request, not an empty fleet.`
: 'Could not reach the server to load endpoints.'
} finally {
loading.value = false
}
}
// Interoperate with PC Details instead of a bespoke detail page: a row only
// navigates when it resolved to a computerid.
function openPc(item) {
if (item.computerid) {
router.push(`/pcs/${item.computerid}`)
}
}
function statusLabel(value) {
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
}
// Relative beats absolute here: "4m ago" answers "is this box alive" at a
// glance, where a timestamp makes the reader do the subtraction.
function formatRelative(value) {
if (!value) return 'never'
const then = new Date(value)
if (isNaN(then)) return '-'
const mins = Math.floor((Date.now() - then.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} h ago`
return `${Math.floor(hours / 24)} d ago`
}
function setStatus(value) {
status.value = value
problemsonly.value = false
applyFilters()
}
function clearFilters() {
status.value = ''
problemsonly.value = false
applyFilters()
}
// Client-side, unlike the other filters: "has a problem" is the health rollup,
// which is assembled per row after pagination rather than in the query. Sorting
// already puts trouble first, so this narrows the page rather than the fleet -
// worth knowing before someone reads the count as fleet-wide.
function toggleProblems() {
problemsonly.value = !problemsonly.value
status.value = ''
applyFilters()
}
function machineLabel(machine) {
if (machine.name && machine.assetnumber && machine.name !== machine.assetnumber) {
return `${machine.assetnumber} ${machine.name}`
}
return machine.assetnumber || machine.name || ''
}
function applyFilters() {
setPage(1)
loadEndpoints()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadEndpoints()
}, 300)
}
function goToPage(p) {
setPage(p)
loadEndpoints()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadEndpoints()
}
</script>
<style scoped>
.clickable-row.disabled {
cursor: default;
opacity: 0.6;
}
.load-error {
padding: 1.5rem;
text-align: center;
color: var(--danger);
}
.fleet-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.fleet-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.8rem 0.95rem;
cursor: pointer;
font: inherit;
/* A button does NOT inherit color the way it inherits font, so without this
the tile renders in the browser default black - invisible on the dark
theme's card. The coloured tiles set their own color and hid the bug; the
plain total did not. */
color: var(--text);
text-align: left;
}
.fleet-tile:hover,
.fleet-tile.on { border-color: var(--primary); }
.fleet-tile .n {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.fleet-tile .l {
font-size: 0.8rem;
color: var(--text-light);
}
.fleet-tile.ok .n { color: var(--success-dark); }
.fleet-tile.warn .n { color: var(--warning-dark); }
.fleet-tile.bad .n { color: var(--danger); }
/* Dark mode redefines --success and --warning to bright variants, but NOT the
-dark ones, which stay tuned for a white background. Left alone they render
dark green and dark amber on the dark card. Scoped to the stamped attribute
(the theme store always sets it) rather than prefers-color-scheme, which
would leak dark styling into light mode on a dark-OS machine. */
[data-theme="dark"] .fleet-tile.ok .n { color: var(--success); }
[data-theme="dark"] .fleet-tile.warn .n { color: var(--warning); }
.health-pill.ok { color: var(--success-dark); }
.health-pill.warning { color: var(--warning-dark); }
.health-pill.critical { color: var(--danger); }
[data-theme="dark"] .health-pill.ok { color: var(--success); }
[data-theme="dark"] .health-pill.warning { color: var(--warning); }
.fleet-filter { max-width: 200px; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.45rem;
vertical-align: 1px;
background: var(--text-light);
}
.status-dot.reporting { background: var(--success); }
.status-dot.quiet { background: var(--warning); }
.status-dot.never { background: var(--text-light); opacity: 0.5; }
/* Health is a phrase, not a score: "eDNC stopped" tells an operator what to
do, where a percentage does not. */
.health-pill {
display: inline-block;
font-size: 0.78rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background: var(--bg);
color: var(--text-light);
}
</style>