Merge origin/main into wilm: reconcile endpoint agent dev with release channel
# Conflicts: # plugins/endpoints/api/routes.py
This commit is contained in:
@@ -4,30 +4,95 @@
|
||||
<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 endpoints..."
|
||||
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>
|
||||
|
||||
<!-- Actions live here rather than per row. A tenth column of buttons
|
||||
pushed itself off the edge of a nowrap table, and selecting one row
|
||||
costs a single click while giving one command path instead of two. -->
|
||||
<div v-if="cancommand && selected.length" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selected.length }} selected</span>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="collectSelected">Collect now</button>
|
||||
<button v-if="canrestart" class="btn btn-warning btn-sm" :disabled="busy" @click="restartSelected">Restart</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="selected = []">Clear</button>
|
||||
</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 v-if="cancommand" class="select-cell">
|
||||
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
|
||||
</th>
|
||||
<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>CPU</th>
|
||||
<th>Memory</th>
|
||||
<th>Last Reported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -39,15 +104,31 @@
|
||||
:class="{ disabled: !item.computerid }"
|
||||
@click="openPc(item)"
|
||||
>
|
||||
<td v-if="cancommand" class="select-cell" @click.stop>
|
||||
<input type="checkbox" :value="item.assetid" v-model="selected" />
|
||||
</td>
|
||||
<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>{{ formatPercent(item.cpupercent) }}</td>
|
||||
<td>{{ formatPercent(item.memorypercent) }}</td>
|
||||
<td>{{ formatTime(item.lastreportedat) }}</td>
|
||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
<td :colspan="cancommand ? 9 : 8" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -69,25 +150,155 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { endpointsApi } from '@/api'
|
||||
import { endpointsApi, computersApi, locationsApi } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
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([])
|
||||
const selected = ref([])
|
||||
const busy = ref(false)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
||||
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
|
||||
const allselected = computed(() =>
|
||||
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
|
||||
|
||||
function toggleAll(event) {
|
||||
selected.value = event.target.checked ? endpoints.value.map(row => row.assetid) : []
|
||||
}
|
||||
|
||||
// What a restart actually costs, in the words that matter on a shop floor. A
|
||||
// dialog listing eight hostnames tells an operator nothing about what they are
|
||||
// about to halt; the machines those PCs serve is the real consequence.
|
||||
function machinesFor(rows) {
|
||||
const names = []
|
||||
rows.forEach(row => (row.machines || []).forEach(machine => {
|
||||
const label = machineLabel(machine)
|
||||
if (label && !names.includes(label)) names.push(label)
|
||||
}))
|
||||
return names
|
||||
}
|
||||
|
||||
function rowsFor(assetids) {
|
||||
return endpoints.value.filter(row => assetids.includes(row.assetid))
|
||||
}
|
||||
|
||||
async function collectSelected() {
|
||||
await runCommands(rowsFor(selected.value), 'collect_now', 'Collect now')
|
||||
}
|
||||
|
||||
async function restartSelected() {
|
||||
const rows = rowsFor(selected.value)
|
||||
if (!confirmRestart(rows)) return
|
||||
await runCommands(rows, 'system_restart', 'Restart')
|
||||
}
|
||||
|
||||
function confirmRestart(rows) {
|
||||
const machines = machinesFor(rows)
|
||||
const hosts = rows.map(row => row.hostname).join(', ')
|
||||
const consequence = machines.length
|
||||
? `\n\nThis STOPS the machines these PCs serve:\n ${machines.join('\n ')}`
|
||||
: '\n\nNo machine is recorded against these PCs.'
|
||||
return confirm(
|
||||
`Restart ${rows.length} computer(s)?\n\n${hosts}${consequence}\n\n`
|
||||
+ 'They reboot immediately with no grace period. Unsaved work is lost.')
|
||||
}
|
||||
|
||||
async function runCommands(rows, action, label) {
|
||||
if (!rows.length) return
|
||||
busy.value = true
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
rows.map(row => endpointsApi.enqueueCommand(row.assetid, action)))
|
||||
|
||||
const queued = results.filter(r => r.status === 'fulfilled').length
|
||||
// 409 means a command is ALREADY in flight for that endpoint - the queue
|
||||
// permits one at a time. That is a skip, not a failure, and saying so
|
||||
// stops it reading as a broken button.
|
||||
const skipped = results.filter(
|
||||
r => r.status === 'rejected' && r.reason?.response?.status === 409).length
|
||||
const failed = results.length - queued - skipped
|
||||
|
||||
if (queued) toast.success(`${label} queued for ${queued} endpoint(s)`)
|
||||
if (skipped) toast.info(`${skipped} skipped - a command is already in flight`)
|
||||
if (failed) {
|
||||
const first = results.find(
|
||||
r => r.status === 'rejected' && r.reason?.response?.status !== 409)
|
||||
toast.error(apiError(first?.reason, `${label} failed on ${failed} endpoint(s)`))
|
||||
}
|
||||
selected.value = []
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -96,12 +307,32 @@ async function loadEndpoints() {
|
||||
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)
|
||||
endpoints.value = response.data.data || []
|
||||
const rows = response.data.data || []
|
||||
endpoints.value = problemsonly.value
|
||||
? rows.filter(row => row.health && row.health.level !== 'ok')
|
||||
: rows
|
||||
// Selections do not survive a reload: the rows they referred to may not be
|
||||
// on this page any more, and acting on an invisible selection is how bulk
|
||||
// tools surprise people.
|
||||
selected.value = []
|
||||
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
|
||||
}
|
||||
@@ -115,14 +346,56 @@ function openPc(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return `${Math.round(value)}%`
|
||||
function statusLabel(value) {
|
||||
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
// 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() {
|
||||
@@ -150,4 +423,117 @@ function changePerPage(newPerPage) {
|
||||
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);
|
||||
/* Matches the global .card radius. The tiles were 6px, which left Fleet
|
||||
slightly rounder than every other page. */
|
||||
border-radius: 0.25rem;
|
||||
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; }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
||||
|
||||
.select-cell { width: 1%; }
|
||||
|
||||
|
||||
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user