An endpoint reported telemetry correctly for hours while the Fleet page showed
"No endpoints found". The PC detail page displayed its panels the whole time, so
ingest was plainly working; only the list was empty.
The list route was the one route in 331 paths registered with a trailing slash:
@endpoints_bp.route('/', methods=['GET']) -> /api/endpoints/
The browser client calls it without one, so Flask answered a 308 redirect to an
ABSOLUTE url. On dev that resolves and nobody notices. Behind the IIS subpath it
cannot: with no ProxyFix, Werkzeug builds that Location from the scheme and host
it believes it has, so it emitted http:// and dropped the /shopdb prefix. A
browser will not follow an https page to http, the request failed, and the
page's catch block rendered the empty state.
Every other route in the plugin worked throughout, because none of them carries
a trailing slash - the detail panels call /endpoints/asset/<id>/status and were
never redirected. Detail works, list empty is the signature of this bug.
Registered as '' now, so the rule is /api/endpoints. That is what
CONTRIBUTING.md's API section spells and what every other list route in the
product already did. The inventory and the generated spec are updated to match.
Also here:
- tests/test_core/test_no_redirecting_routes.py fails the build if any /api
rule is registered with a trailing slash and strict slashes. Verified by
planting the violation: it named the offending route, then passed once
reverted. It takes the shared app fixture rather than building its own
application - doing that rebinds SQLAlchemy and leaves the NEXT module
without tables, which showed up as three unrelated "no such table: settings"
failures visible only in a full run.
- The Fleet page now renders "Could not load endpoints (HTTP nnn). This is a
failed request, not an empty fleet." A list that reports a 403 and an empty
fleet identically is what made this take as long as it did.
- TRUSTED_PROXY_COUNT (default 0) applies ProxyFix when set. Off by default on
purpose: it makes X-Forwarded-* trusted, and a client can send those itself,
while the login rate limit and account lockout are keyed on the client
address. Set it to 1 on an instance behind IIS.
Full suite 2124 passed, vitest 260 passed, naming and docs gates green.
179 lines
4.8 KiB
Vue
179 lines
4.8 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Fleet</h2>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search endpoints..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
</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>Hostname</th>
|
|
<th>Logged-in User</th>
|
|
<th>Uptime</th>
|
|
<th>CPU</th>
|
|
<th>Memory</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>{{ item.hostname || '-' }}</td>
|
|
<td>{{ item.loggedinuser || '-' }}</td>
|
|
<td>{{ item.uptimedisplay || '-' }}</td>
|
|
<td>{{ formatPercent(item.cpupercent) }}</td>
|
|
<td>{{ formatPercent(item.memorypercent) }}</td>
|
|
<td>{{ formatTime(item.lastreportedat) }}</td>
|
|
</tr>
|
|
<tr v-if="endpoints.length === 0">
|
|
<td colspan="6" 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)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadEndpoints()
|
|
})
|
|
|
|
async function loadEndpoints() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value
|
|
}
|
|
if (search.value) params.search = search.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 formatPercent(value) {
|
|
if (value === null || value === undefined) return '-'
|
|
return `${Math.round(value)}%`
|
|
}
|
|
|
|
function formatTime(value) {
|
|
if (!value) return '-'
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
|
|
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);
|
|
}
|
|
</style>
|