Add bulk actions to the Fleet list, with a confirmation that names machines
Select endpoints, then Collect now or Restart across the selection. Restart is
gated on endpoints.restart, both are re-checked server-side.
The confirmation names the MACHINES, not just the hostnames:
Restart 2 computer(s)?
GJX9B2Z3ESF, HOST-7
This STOPS the machines these PCs serve:
DT Office
A dialog listing eight hostnames tells an operator nothing about what they are
about to halt, and that is the whole consequence of the action on a shop floor.
Where no machine is recorded it says so, because 'none listed' and 'we did not
check' are different things at 6am. Only possible now the Machine column exists.
A 409 is reported as a SKIP, not a failure. The queue permits one command in
flight per endpoint, so a bulk action across eight PCs where two are already
busy says '6 queued' and '2 skipped - a command is already in flight'. Folding
those into an error count would make a working button look broken.
Selections clear on every reload: the rows they referred to may not be on the
new page, and acting on an invisible selection is how bulk tools surprise
people.
No per-row buttons. They were built first, and a tenth column on a nowrap table
pushed itself off the edge - selecting one row costs a single click and leaves
one command path instead of two.
Full suite 2124 passed, vitest 260 passed, naming gate green.
This commit is contained in:
@@ -61,6 +61,16 @@
|
||||
</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>
|
||||
|
||||
@@ -73,6 +83,9 @@
|
||||
<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>
|
||||
@@ -91,6 +104,9 @@
|
||||
: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>
|
||||
@@ -112,7 +128,7 @@
|
||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td colspan="8" 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>
|
||||
@@ -134,9 +150,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
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'
|
||||
|
||||
@@ -163,6 +182,86 @@ 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
|
||||
|
||||
@@ -219,6 +318,10 @@ async function loadEndpoints() {
|
||||
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)
|
||||
@@ -390,6 +493,23 @@ function changePerPage(newPerPage) {
|
||||
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user