Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption import, a versioned agent binary store with upload and rollback, remote-BuildKit CI, tray version reporting, and UI fixes. Applied as a patch again - the published mirror rewrites hashes, so the branches have unrelated histories and git refuses to merge them. Reviewed by three agents before merging. Four things fixed here. THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in publish-images.yml sits at column 0 instead of indented under steps:, so the whole file is invalid YAML and GitHub would refuse it. Their entire GHCR distribution channel - which Start-ShopDBDev.ps1 and the compose files consume - was broken at the source and clearly never run. Indented; all five workflows now parse. THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the approval packet from the same upload and then asked the writer whether they matched, so packet_matches_plan compared a hash against itself. The documented contract - "a human approves a specific, frozen plan, and the writer can prove nothing drifted" - did not exist in the web path: an admin could preview file A and commit file B, gated only by the needs-create group NAMES they ticked. The preview's planhash is now required at commit and checked against a freshly rebuilt plan. Their tests committed without previewing, which is what let this through; they go through the real flow now, and three new tests pin it - all confirmed to fail with the check stubbed out. endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a ZIP"; with the upload routes it also means "choose the executable every future package installs and runs as SYSTEM". Before this, staging needed filesystem access to the server. The permission description still said only "generate and download a package", so an admin granting it could not see what they were granting. Reworded. NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size and timestamp; re-uploading a version replaces the bytes in place under the same label. A sha256 of the stored file is now recorded, and the upload is audit-logged with it - after an incident there was previously no answer to who put that binary on the fleet. Also documented the nine new routes in docs/api-inventory.json (that gate cannot run on the mirror, so it falls to us at promotion time again), regenerated the spec and the project map, and kept our own docs where they conflicted: docs/ is scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file rather than our 737-line canonical one. Their journal section is appended to ours. Contract version comes across as 0.30.0 for a genuinely additive surface. Suite 2369, frontend 324, gate green. Scope: product
542 lines
18 KiB
Vue
542 lines
18 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>
|
|
|
|
<!-- 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>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 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>{{ formatRelative(item.lastreportedat) }}</td>
|
|
</tr>
|
|
<tr v-if="endpoints.length === 0">
|
|
<td :colspan="cancommand ? 9 : 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, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
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 {
|
|
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
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// Tag the origin so PC Details and the edit form return here (Fleet)
|
|
// instead of the PCs list. Absent for the normal PCs-list entry path.
|
|
router.push(`/pcs/${item.computerid}?from=fleet`)
|
|
}
|
|
}
|
|
|
|
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);
|
|
/* 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>
|