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.
418 lines
14 KiB
Vue
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>
|