The fleet list showed hostname, user, uptime, CPU, memory and last-reported,
sorted by last-reported descending. Two of those columns have never held a
value, and that sort floats the healthiest endpoints to the top and buries the
ones somebody needs to act on.
What a shop-floor fleet view is actually for is which MACHINE stops if a PC is
dead, so that is now a column. Both edge directions are checked, deduplicated,
and constrained to assets typed 'machine' - without that last filter the column
filled up with office locations that happen to carry a controls edge.
Also here:
- Sorted WORST FIRST: never reported, then quiet, then reporting. Expressed
as a sortable SQL rank rather than a label applied after the fact, so the
order survives pagination.
- Health rollup as a PHRASE, not a score: 'eDNC stopped', '2 critical'. A
percentage does not tell an operator what to do.
- Filters for status, PC type, location and business unit; a sort parameter
(the route accepted none); and search widened from hostname alone to asset
number and the machine number a PC serves, which is how people actually
look a bay up.
- CPU and Memory columns dropped. The agent declares those fields and has
never assigned them, so they rendered '-' on every row.
- A summary strip wired to the dashboard routes, which existed and which
nothing called.
Two bugs found while building it:
The health rollup first read a 'running' boolean, but service rows carry a
STATE STRING and the vocabulary varies by source, so every service would have
reported as stopped. The panel and the rollup now share one _service_running()
helper; two copies of that rule would drift.
The summary counted quiet as total - reporting, which folded never-reported
endpoints in with quiet ones. Those are different failures - an enrolment or
key problem versus a machine that went down - so 'never' is counted separately.
Per-page batching for machines and critical-event counts: three queries per
page rather than two per row.
Full suite 2124 passed, vitest 260 passed, naming and contract gates green.
331 lines
10 KiB
Vue
331 lines
10 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 === '' }" @click="setStatus('')">
|
|
<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>
|
|
</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="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(m => m.assetnumber).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 } 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)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadEndpoints()
|
|
loadSummary()
|
|
})
|
|
|
|
async function loadSummary() {
|
|
try {
|
|
const response = await endpointsApi.dashboardSummary()
|
|
summary.value = response.data.data || null
|
|
} 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
|
|
|
|
loadError.value = ''
|
|
const response = await endpointsApi.list(params)
|
|
endpoints.value = response.data.data || []
|
|
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
|
|
applyFilters()
|
|
}
|
|
|
|
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;
|
|
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); }
|
|
|
|
.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);
|
|
}
|
|
|
|
.health-pill.ok { color: var(--success-dark); }
|
|
.health-pill.warning { color: var(--warning-dark); }
|
|
.health-pill.critical { color: var(--danger); }
|
|
</style>
|