348 lines
11 KiB
Vue
348 lines
11 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;
|
|
/* 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>
|