Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
1209 lines
39 KiB
Vue
1209 lines
39 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Enrollment</h2>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
Each endpoint agent authenticates with its own credential, readable only
|
|
by SYSTEM on that PC. Agents from {{ target }} onward enroll themselves
|
|
with the shared key they already hold; older agents move over by
|
|
upgrading. Use Migration to move the fleet, and Credentials to approve
|
|
requests, un-enroll a PC, and retire the shared key.
|
|
</p>
|
|
|
|
<div class="enroll-tabs">
|
|
<button
|
|
type="button"
|
|
class="enroll-tab"
|
|
:class="{ active: tab === 'migration' }"
|
|
@click="tab = 'migration'"
|
|
>
|
|
Migration
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="enroll-tab"
|
|
:class="{ active: tab === 'credentials' }"
|
|
@click="tab = 'credentials'"
|
|
>
|
|
Credentials
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ==================================================================
|
|
Migration
|
|
=============================================================== -->
|
|
<section v-if="tab === 'migration'" class="migration-tab">
|
|
<div v-if="migrationError" class="load-error">{{ migrationError }}</div>
|
|
|
|
<div class="enroll-tiles">
|
|
<button
|
|
v-for="tile in migrationTiles"
|
|
:key="tile.state"
|
|
type="button"
|
|
class="enroll-tile"
|
|
:class="[tile.tone, { on: migrationState === tile.state }]"
|
|
@click="toggleMigrationState(tile.state)"
|
|
>
|
|
<span class="n" :data-count="tile.state">{{ migrationCounts[tile.state] || 0 }}</span>
|
|
<span class="l">{{ tile.label }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="progress-row">
|
|
<div class="progress-track">
|
|
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
|
|
</div>
|
|
<span class="progress-text">
|
|
{{ migrationCounts.enrolled || 0 }} of {{ migrationTotal }} enrolled
|
|
</span>
|
|
</div>
|
|
|
|
<div class="filters">
|
|
<input
|
|
v-model="migrationSearch"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search hostname..."
|
|
@input="debouncedMigrationSearch"
|
|
/>
|
|
<select v-model="migrationState" class="form-control enroll-filter" @change="reloadMigration">
|
|
<option value="">All states</option>
|
|
<option v-for="tile in migrationTiles" :key="tile.state" :value="tile.state">
|
|
{{ tile.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="isadmin" class="bulk-bar">
|
|
<span class="bulk-count">{{ selected.length }} selected</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm upgrade-selected"
|
|
:disabled="busy || !selected.length"
|
|
@click="upgradeSelected"
|
|
>
|
|
Upgrade selected
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary btn-sm upgrade-next"
|
|
:disabled="busy || !(migrationCounts.ready > 0)"
|
|
@click="upgradeNext"
|
|
>
|
|
Upgrade next {{ waveSize }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="migrationLoading" class="loading">Loading...</div>
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th v-if="isadmin" class="select-cell">
|
|
<input
|
|
type="checkbox"
|
|
:checked="allReadySelected"
|
|
:disabled="!readyRows.length"
|
|
title="Select every ready PC on this page"
|
|
@change="toggleAllReady"
|
|
/>
|
|
</th>
|
|
<th>Hostname</th>
|
|
<th>State</th>
|
|
<th>Agent version</th>
|
|
<th>Last reported</th>
|
|
<th>Blockers</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!migrationRows.length">
|
|
<td :colspan="isadmin ? 6 : 5" class="empty-cell">No computers match.</td>
|
|
</tr>
|
|
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
|
|
<td v-if="isadmin" class="select-cell">
|
|
<input
|
|
v-if="isSelectable(row)"
|
|
v-model="selected"
|
|
type="checkbox"
|
|
:value="row.assetid"
|
|
/>
|
|
</td>
|
|
<td>{{ row.hostname || '-' }}</td>
|
|
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
|
|
<td>{{ row.agentversion || '-' }}</td>
|
|
<td>{{ formatDate(row.lastreportedat) }}</td>
|
|
<td class="blockers">
|
|
{{ (row.blockers || []).join(', ') || '-' }}
|
|
<span v-if="isadmin && isTrustRetryable(row)" class="retry-hint">
|
|
Retry after fixing trust
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<PaginationBar
|
|
:page="migrationPage"
|
|
:total-pages="migrationTotalPages"
|
|
@update:page="goToMigrationPage"
|
|
/>
|
|
</div>
|
|
|
|
<h3 class="section-title">Waves</h3>
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Wave</th>
|
|
<th>Created</th>
|
|
<th>By</th>
|
|
<th>Size</th>
|
|
<th>Queued</th>
|
|
<th>Enrolled</th>
|
|
<th>Failed</th>
|
|
<th>Progress</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!waves.length">
|
|
<td colspan="8" class="empty-cell">No waves yet.</td>
|
|
</tr>
|
|
<tr v-for="wave in waves" :key="wave.waveid">
|
|
<td>{{ wave.waveid }}</td>
|
|
<td>{{ formatDate(wave.createdat) }}</td>
|
|
<td>{{ wave.createdby || '-' }}</td>
|
|
<td>{{ wave.size }}</td>
|
|
<td>{{ wave.queued }}</td>
|
|
<td>{{ wave.enrolled }}</td>
|
|
<td>{{ wave.failed }}</td>
|
|
<td>
|
|
<div class="progress-track small">
|
|
<div class="progress-fill" :style="{ width: wavePercent(wave) + '%' }"></div>
|
|
</div>
|
|
<span class="field-hint">{{ wave.done ? 'Done' : 'In progress' }}</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ==================================================================
|
|
Credentials
|
|
=============================================================== -->
|
|
<section v-else class="credentials-tab">
|
|
<div v-if="enrollmentError" class="load-error">{{ enrollmentError }}</div>
|
|
|
|
<div class="card form-card">
|
|
<h3>Settings</h3>
|
|
<label class="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle-sharedkey"
|
|
:checked="enrollmentSettings.sharedkeyallowed"
|
|
:disabled="!isadmin || busy"
|
|
@change="onSharedKeyToggle($event)"
|
|
/>
|
|
<span>
|
|
<strong>Shared key allowed.</strong> PCs that have not enrolled may
|
|
still report with the fleet shared key. An enrolled PC is always
|
|
refused the shared key. Turn this off once every PC is enrolled.
|
|
</span>
|
|
</label>
|
|
<label class="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle-autoenroll"
|
|
:checked="enrollmentSettings.autoenroll"
|
|
:disabled="!isadmin || busy"
|
|
@change="onAutoEnrollToggle($event)"
|
|
/>
|
|
<span>
|
|
<strong>Auto-enroll.</strong> Issue a credential without approval
|
|
when the hostname matches exactly one computer and the serial number
|
|
on record matches. Anything else waits below for an admin.
|
|
</span>
|
|
</label>
|
|
<label class="number-row">
|
|
<span>
|
|
<strong>Pending request limit.</strong> How many requests may wait
|
|
here at once (setting
|
|
<code>plugin.endpoints.maxpendingenrollments</code>). At the limit,
|
|
a PC we have never seen is told to try later, while PCs already in
|
|
this list keep asking and keep being approved. 0 removes the limit.
|
|
/enroll takes no login, so this is what stops the queue growing
|
|
without bound.
|
|
</span>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
class="input-maxpending"
|
|
v-model.number="pendingCapInput"
|
|
:disabled="!isadmin || busy"
|
|
@change="onPendingCapChange()"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="enroll-tiles">
|
|
<div class="enroll-tile static ok">
|
|
<span class="n" data-count="enrolled">{{ enrollmentCounts.enrolled || 0 }}</span>
|
|
<span class="l">Enrolled</span>
|
|
</div>
|
|
<div class="enroll-tile static warn">
|
|
<span class="n" data-count="notenrolled">{{ enrollmentCounts.notenrolled || 0 }}</span>
|
|
<span class="l">Not enrolled</span>
|
|
</div>
|
|
<div class="enroll-tile static">
|
|
<span class="n" data-count="pending">{{ enrollmentCounts.pending || 0 }}</span>
|
|
<span class="l">Pending requests</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="section-title">Requests</h3>
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Hostname</th>
|
|
<th>Serial</th>
|
|
<th>Device id</th>
|
|
<th>Reason</th>
|
|
<th>Status</th>
|
|
<th>Source</th>
|
|
<th>Last seen</th>
|
|
<th v-if="isadmin"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!requests.length">
|
|
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No enrollment requests.</td>
|
|
</tr>
|
|
<template v-for="request in requests" :key="request.requestid">
|
|
<tr class="request-row">
|
|
<td>{{ request.hostname }}</td>
|
|
<td>{{ request.serialnumber || '-' }}</td>
|
|
<td>{{ request.deviceid || '-' }}</td>
|
|
<td>{{ request.reason || '-' }}</td>
|
|
<td>{{ request.status }}</td>
|
|
<td>{{ request.sourceip || '-' }}</td>
|
|
<td>{{ formatDate(request.lastseenat) }}</td>
|
|
<td v-if="isadmin" class="action-cell">
|
|
<template v-if="request.status === 'pending'">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm approve-open"
|
|
:disabled="busy"
|
|
@click="openApprove(request)"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary btn-sm reject"
|
|
:disabled="busy"
|
|
@click="reject(request)"
|
|
>
|
|
Reject
|
|
</button>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="approving && approving.requestid === request.requestid" class="approve-row">
|
|
<td :colspan="isadmin ? 8 : 7">
|
|
<div class="approve-form">
|
|
<label class="field-label">Issue the credential for computer</label>
|
|
<input
|
|
v-model="approving.search"
|
|
type="text"
|
|
class="form-control asset-search"
|
|
placeholder="Search hostname or serial..."
|
|
@input="debouncedAssetSearch"
|
|
/>
|
|
<select v-model="approving.assetid" class="form-control asset-picker">
|
|
<option :value="null">Choose a computer</option>
|
|
<option
|
|
v-for="option in approving.options"
|
|
:key="option.assetid"
|
|
:value="option.assetid"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm approve-confirm"
|
|
:disabled="busy || !approving.assetid"
|
|
@click="approve"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="approving = null">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<p class="field-hint">
|
|
The PC receives its credential the next time it asks:
|
|
about a minute for one that started asking recently,
|
|
slowing to 15 minutes once it has been pending a while.
|
|
Only the PC that made this request can collect it.
|
|
If the computer record has no hostname, approving
|
|
records the one the PC reports, which it needs to
|
|
authenticate afterwards.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="section-title">Computers</h3>
|
|
<div class="filters">
|
|
<input
|
|
v-model="enrollmentSearch"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search hostname..."
|
|
@input="debouncedEnrollmentSearch"
|
|
/>
|
|
<select v-model="enrollmentState" class="form-control enroll-filter" @change="reloadEnrollment">
|
|
<option value="">All states</option>
|
|
<option value="enrolled">Enrolled</option>
|
|
<option value="notenrolled">Not enrolled</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="revoked">Revoked</option>
|
|
</select>
|
|
</div>
|
|
<div class="card">
|
|
<div v-if="enrollmentLoading" class="loading">Loading...</div>
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Hostname</th>
|
|
<th>Serial</th>
|
|
<th>State</th>
|
|
<th>Credential</th>
|
|
<th>Issued</th>
|
|
<th>Last used</th>
|
|
<th>Via</th>
|
|
<th v-if="isadmin"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!credentialRows.length">
|
|
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
|
|
</tr>
|
|
<template v-for="row in credentialRows" :key="row.assetid">
|
|
<tr class="credential-row">
|
|
<td>{{ row.hostname || '-' }}</td>
|
|
<td>{{ row.serialnumber || '-' }}</td>
|
|
<td>
|
|
<span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span>
|
|
<span
|
|
v-if="row.reenrollblocked"
|
|
class="state-chip blocked block-chip"
|
|
:title="row.reenrollblockedreason || ''"
|
|
>Re-enroll blocked</span>
|
|
</td>
|
|
<td>
|
|
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>{{ formatDate(row.issuedat) }}</td>
|
|
<td>{{ formatDate(row.lastusedat) }}</td>
|
|
<td>{{ row.enrolledvia || '-' }}</td>
|
|
<td v-if="isadmin" class="action-cell">
|
|
<button
|
|
v-if="row.state === 'enrolled'"
|
|
type="button"
|
|
class="btn btn-danger btn-sm revoke"
|
|
:disabled="busy"
|
|
@click="openRevoke(row)"
|
|
>
|
|
Un-enroll
|
|
</button>
|
|
<button
|
|
v-if="row.reenrollblocked"
|
|
type="button"
|
|
class="btn btn-secondary btn-sm allow-reenroll"
|
|
:disabled="busy"
|
|
@click="allowReenroll(row)"
|
|
>
|
|
Allow re-enrollment
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="revoking && revoking.assetid === row.assetid" class="revoke-row">
|
|
<td :colspan="isadmin ? 8 : 7">
|
|
<p class="field-hint">
|
|
Un-enroll {{ revoking.hostname }}? Its credential stops working
|
|
immediately. {{ revokeFallback }}
|
|
</p>
|
|
<label class="checkbox-row">
|
|
<input v-model="revoking.block" type="checkbox" class="revoke-block" />
|
|
<span>
|
|
Keep this PC from enrolling again (use before rolling back to
|
|
an older agent, or for a compromised PC)
|
|
</span>
|
|
</label>
|
|
<div class="approve-form">
|
|
<input
|
|
v-if="revoking.block"
|
|
v-model="revoking.reason"
|
|
type="text"
|
|
maxlength="64"
|
|
class="form-control revoke-reason"
|
|
placeholder="Reason (optional)"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="btn btn-danger btn-sm revoke-confirm"
|
|
:disabled="busy"
|
|
@click="confirmRevoke"
|
|
>
|
|
Un-enroll
|
|
</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="revoking = null">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<PaginationBar
|
|
:page="enrollmentPage"
|
|
:total-pages="enrollmentTotalPages"
|
|
@update:page="goToEnrollmentPage"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import api, { computersApi } from '@/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
|
|
const PERPAGE = 50
|
|
const POLL_MS = 30000
|
|
|
|
const authStore = useAuthStore()
|
|
const toast = useToast()
|
|
// UI convenience only; every write is re-checked server-side.
|
|
const isadmin = computed(() => authStore.isAdmin)
|
|
|
|
const tab = ref('migration')
|
|
const busy = ref(false)
|
|
const waveSize = 25
|
|
|
|
// ---- Migration --------------------------------------------------------
|
|
|
|
const migrationTiles = [
|
|
{ state: 'enrolled', label: 'Enrolled', tone: 'ok' },
|
|
{ state: 'ready', label: 'Ready', tone: 'ok' },
|
|
{ state: 'blocked', label: 'Blocked', tone: 'bad' },
|
|
{ state: 'upgrading', label: 'Upgrading', tone: '' },
|
|
{ state: 'pending', label: 'Pending', tone: 'warn' },
|
|
{ state: 'notreporting', label: 'Not reporting', tone: '' }
|
|
]
|
|
|
|
const target = ref('1.20.0')
|
|
const migrationCounts = ref({})
|
|
const migrationRows = ref([])
|
|
const waves = ref([])
|
|
const migrationPage = ref(1)
|
|
const migrationTotalPages = ref(1)
|
|
const migrationState = ref('')
|
|
const migrationSearch = ref('')
|
|
const migrationLoading = ref(false)
|
|
const migrationError = ref('')
|
|
const selected = ref([])
|
|
|
|
const migrationTotal = computed(() =>
|
|
migrationTiles.reduce((sum, tile) => sum + (Number(migrationCounts.value[tile.state]) || 0), 0))
|
|
|
|
const enrolledPercent = computed(() => {
|
|
if (!migrationTotal.value) return 0
|
|
return Math.round(100 * (Number(migrationCounts.value.enrolled) || 0) / migrationTotal.value)
|
|
})
|
|
|
|
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready'))
|
|
|
|
// The server cannot see a PC's trust store, so this blocker only clears when
|
|
// an admin queues the PC again after fixing trust. Next N never picks it.
|
|
const TRUST_BLOCKER = 'server certificate not trusted by this PC'
|
|
|
|
function isTrustRetryable(row) {
|
|
const blockers = row.blockers || []
|
|
return row.state === 'blocked' && blockers.length > 0
|
|
&& blockers.every(blocker => blocker === TRUST_BLOCKER)
|
|
}
|
|
|
|
function isSelectable(row) {
|
|
return row.state === 'ready' || isTrustRetryable(row)
|
|
}
|
|
const allReadySelected = computed(() =>
|
|
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid)))
|
|
|
|
function toggleAllReady(event) {
|
|
selected.value = event.target.checked ? readyRows.value.map(row => row.assetid) : []
|
|
}
|
|
|
|
function toggleMigrationState(state) {
|
|
migrationState.value = migrationState.value === state ? '' : state
|
|
reloadMigration()
|
|
}
|
|
|
|
async function loadMigration({ quiet = false } = {}) {
|
|
if (!quiet) migrationLoading.value = true
|
|
try {
|
|
const params = { page: migrationPage.value, perpage: PERPAGE }
|
|
if (migrationState.value) params.state = migrationState.value
|
|
if (migrationSearch.value) params.search = migrationSearch.value
|
|
const response = await api.get('/endpoints/enrollment/migration', { params })
|
|
const data = response.data.data || {}
|
|
if (data.target) target.value = data.target
|
|
migrationCounts.value = data.counts || {}
|
|
migrationRows.value = data.computers || []
|
|
waves.value = data.waves || []
|
|
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
|
|
// Drop selections that are no longer selectable (queued by a wave, went offline).
|
|
const selectableIds = new Set(migrationRows.value.filter(isSelectable).map(row => row.assetid))
|
|
selected.value = selected.value.filter(id => selectableIds.has(id))
|
|
migrationError.value = ''
|
|
} catch (error) {
|
|
migrationError.value = apiError(error, 'Could not load migration status')
|
|
} finally {
|
|
migrationLoading.value = false
|
|
}
|
|
}
|
|
|
|
function reloadMigration() {
|
|
migrationPage.value = 1
|
|
loadMigration()
|
|
}
|
|
|
|
function goToMigrationPage(page) {
|
|
migrationPage.value = page
|
|
loadMigration()
|
|
}
|
|
|
|
let migrationSearchTimer = null
|
|
function debouncedMigrationSearch() {
|
|
clearTimeout(migrationSearchTimer)
|
|
migrationSearchTimer = setTimeout(reloadMigration, 300)
|
|
}
|
|
|
|
function waveConfirmText(count) {
|
|
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
|
|
+ 'Each PC downloads the new agent and restarts its service. Once the new '
|
|
+ 'agent is running it enrolls automatically with the shared key it already '
|
|
+ 'holds; no further action is needed unless it lands in Pending.\n\n'
|
|
+ 'PCs on agents older than 1.20 must trust the server certificate before '
|
|
+ 'they can download the upgrade (see runbook).'
|
|
}
|
|
|
|
async function startWave(body, count) {
|
|
if (!count) return
|
|
if (!confirm(waveConfirmText(count))) return
|
|
busy.value = true
|
|
try {
|
|
const response = await api.post('/endpoints/enrollment/migration/wave', body)
|
|
const data = response.data.data || {}
|
|
const queued = (data.queued || []).length
|
|
const skipped = data.skipped || []
|
|
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`)
|
|
const retried = data.retried || []
|
|
if (retried.length) {
|
|
toast.info(`${retried.length} queued again despite the certificate trust blocker: `
|
|
+ retried.slice(0, 5).join(', '))
|
|
}
|
|
if (skipped.length) {
|
|
toast.info(`${skipped.length} skipped: `
|
|
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', '))
|
|
}
|
|
selected.value = []
|
|
await loadMigration({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not start the wave'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
function upgradeSelected() {
|
|
const assetids = [...selected.value]
|
|
return startWave({ assetids }, assetids.length)
|
|
}
|
|
|
|
function upgradeNext() {
|
|
const count = Math.min(waveSize, Number(migrationCounts.value.ready) || 0)
|
|
return startWave({ next: waveSize }, count)
|
|
}
|
|
|
|
function wavePercent(wave) {
|
|
if (!wave.size) return 0
|
|
return Math.round(100 * ((Number(wave.enrolled) || 0) + (Number(wave.failed) || 0)) / wave.size)
|
|
}
|
|
|
|
// ---- Credentials ------------------------------------------------------
|
|
|
|
const DEFAULT_PENDING_CAP = 500
|
|
|
|
const enrollmentSettings = ref({
|
|
sharedkeyallowed: true,
|
|
autoenroll: true,
|
|
maxpendingenrollments: DEFAULT_PENDING_CAP
|
|
})
|
|
// Bound with v-model rather than :value so the field holds what the operator
|
|
// typed until it is accepted or rejected; a one-way binding fights the edit.
|
|
const pendingCapInput = ref(DEFAULT_PENDING_CAP)
|
|
const enrollmentCounts = ref({})
|
|
const credentialRows = ref([])
|
|
const requests = ref([])
|
|
const enrollmentPage = ref(1)
|
|
const enrollmentTotalPages = ref(1)
|
|
const enrollmentState = ref('')
|
|
const enrollmentSearch = ref('')
|
|
const enrollmentLoading = ref(false)
|
|
const enrollmentError = ref('')
|
|
const approving = ref(null)
|
|
|
|
async function loadEnrollment({ quiet = false } = {}) {
|
|
if (!quiet) enrollmentLoading.value = true
|
|
try {
|
|
const params = { page: enrollmentPage.value, perpage: PERPAGE }
|
|
if (enrollmentState.value) params.state = enrollmentState.value
|
|
if (enrollmentSearch.value) params.search = enrollmentSearch.value
|
|
const response = await api.get('/endpoints/enrollment', { params })
|
|
const data = response.data.data || {}
|
|
enrollmentSettings.value = {
|
|
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
|
autoenroll: data.settings?.autoenroll !== false,
|
|
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
|
|
? Number(data.settings.maxpendingenrollments)
|
|
: DEFAULT_PENDING_CAP
|
|
}
|
|
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
|
|
enrollmentCounts.value = data.counts || {}
|
|
credentialRows.value = data.computers || []
|
|
requests.value = data.requests || []
|
|
enrollmentTotalPages.value = response.data.meta?.pagination?.totalpages || 1
|
|
enrollmentError.value = ''
|
|
} catch (error) {
|
|
enrollmentError.value = apiError(error, 'Could not load enrollment')
|
|
} finally {
|
|
enrollmentLoading.value = false
|
|
}
|
|
}
|
|
|
|
function reloadEnrollment() {
|
|
enrollmentPage.value = 1
|
|
loadEnrollment()
|
|
}
|
|
|
|
function goToEnrollmentPage(page) {
|
|
enrollmentPage.value = page
|
|
loadEnrollment()
|
|
}
|
|
|
|
let enrollmentSearchTimer = null
|
|
function debouncedEnrollmentSearch() {
|
|
clearTimeout(enrollmentSearchTimer)
|
|
enrollmentSearchTimer = setTimeout(reloadEnrollment, 300)
|
|
}
|
|
|
|
async function saveSettings(next) {
|
|
busy.value = true
|
|
try {
|
|
await api.put('/endpoints/enrollment/settings', next)
|
|
toast.success('Enrollment settings saved')
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not save enrollment settings'))
|
|
} finally {
|
|
busy.value = false
|
|
await loadEnrollment({ quiet: true })
|
|
}
|
|
}
|
|
|
|
function onSharedKeyToggle(event) {
|
|
const turningOn = event.target.checked
|
|
if (!turningOn) {
|
|
const notenrolled = Number(enrollmentCounts.value.notenrolled) || 0
|
|
const answer = prompt(
|
|
'Turn OFF the shared key?\n\n'
|
|
+ `${notenrolled} PC(s) are not enrolled yet. Every one of them stops `
|
|
+ 'reporting, stops receiving commands, and cannot enroll on its own until '
|
|
+ 'the shared key is turned back on or an admin approves its request.\n\n'
|
|
+ 'Type OFF to confirm.')
|
|
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
|
event.target.checked = true
|
|
return
|
|
}
|
|
}
|
|
return saveSettings({
|
|
sharedkeyallowed: turningOn,
|
|
autoenroll: enrollmentSettings.value.autoenroll
|
|
})
|
|
}
|
|
|
|
function onAutoEnrollToggle(event) {
|
|
return saveSettings({
|
|
sharedkeyallowed: enrollmentSettings.value.sharedkeyallowed,
|
|
autoenroll: event.target.checked
|
|
})
|
|
}
|
|
|
|
function onPendingCapChange() {
|
|
const wanted = Math.trunc(Number(pendingCapInput.value))
|
|
if (!Number.isFinite(wanted) || wanted < 0) {
|
|
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
|
|
toast.error('The pending request limit must be 0 or a whole number')
|
|
return
|
|
}
|
|
return saveSettings({ maxpendingenrollments: wanted })
|
|
}
|
|
|
|
function openApprove(request) {
|
|
const options = []
|
|
if (request.matchedassetid) {
|
|
options.push({ assetid: request.matchedassetid, label: `${request.hostname} (matched)` })
|
|
}
|
|
approving.value = {
|
|
requestid: request.requestid,
|
|
hostname: request.hostname,
|
|
assetid: request.matchedassetid || null,
|
|
search: '',
|
|
options
|
|
}
|
|
}
|
|
|
|
let assetSearchTimer = null
|
|
function debouncedAssetSearch() {
|
|
clearTimeout(assetSearchTimer)
|
|
assetSearchTimer = setTimeout(searchAssets, 300)
|
|
}
|
|
|
|
async function searchAssets() {
|
|
const current = approving.value
|
|
if (!current || !current.search) return
|
|
try {
|
|
const response = await computersApi.list({ search: current.search, perpage: 20 })
|
|
const found = (response.data.data || []).map(item => ({
|
|
assetid: item.assetid,
|
|
label: `${item.computer?.hostname || item.name || item.assetid}`
|
|
+ (item.serialnumber ? ` (${item.serialnumber})` : '')
|
|
}))
|
|
const keep = current.options.filter(option => option.assetid === current.assetid)
|
|
const merged = [...keep]
|
|
found.forEach(option => {
|
|
if (!merged.some(existing => existing.assetid === option.assetid)) merged.push(option)
|
|
})
|
|
current.options = merged
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not search computers'))
|
|
}
|
|
}
|
|
|
|
// The approval also stamps the request's hostname onto the computer when that
|
|
// record has none - without it the PC collects its credential and then 403s on
|
|
// every call, because the server resolves the hostname it sends to nothing. A
|
|
// computer already carrying a DIFFERENT hostname comes back as a 409 naming
|
|
// both, and only an explicit second answer renames it.
|
|
async function postApprove(current, sethostname) {
|
|
const body = { assetid: current.assetid }
|
|
if (sethostname) body.sethostname = true
|
|
const response = await api.post(
|
|
`/endpoints/enrollment/requests/${current.requestid}/approve`, body)
|
|
const result = response?.data?.data?.hostnameresult
|
|
if (result === 'set') {
|
|
toast.success(`Approved ${current.hostname}; hostname recorded on that PC`)
|
|
} else if (result === 'overwritten') {
|
|
toast.success(`Approved ${current.hostname}; that PC was renamed to it`)
|
|
} else {
|
|
toast.success(`Approved ${current.hostname}`)
|
|
}
|
|
approving.value = null
|
|
await loadEnrollment({ quiet: true })
|
|
}
|
|
|
|
async function approve() {
|
|
const current = approving.value
|
|
if (!current || !current.assetid) return
|
|
const option = current.options.find(item => item.assetid === current.assetid)
|
|
if (!confirm(`Approve the request from ${current.hostname} and issue a credential for `
|
|
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
|
|
busy.value = true
|
|
try {
|
|
await postApprove(current, false)
|
|
} catch (error) {
|
|
const details = error?.response?.data?.data?.error?.details
|
|
const message = apiError(error, 'Could not approve the request')
|
|
if (details?.hostnameconflict) {
|
|
if (confirm(`${message}\n\nSet the hostname on this PC?`)) {
|
|
try {
|
|
await postApprove(current, true)
|
|
return
|
|
} catch (retryError) {
|
|
toast.error(apiError(retryError, 'Could not approve the request'))
|
|
return
|
|
}
|
|
}
|
|
toast.error(message)
|
|
return
|
|
}
|
|
toast.error(message)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function reject(request) {
|
|
if (!confirm(`Reject the enrollment request from ${request.hostname}?`)) return
|
|
busy.value = true
|
|
try {
|
|
await api.post(`/endpoints/enrollment/requests/${request.requestid}/reject`)
|
|
toast.success(`Rejected ${request.hostname}`)
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not reject the request'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
const revoking = ref(null)
|
|
|
|
const revokeFallback = computed(() => {
|
|
if (revoking.value?.block) {
|
|
return 'It stays un-enrolled and keeps reporting on the shared key while that is allowed.'
|
|
}
|
|
return enrollmentSettings.value.sharedkeyallowed
|
|
? 'It falls back to the shared key and enrolls again on its own.'
|
|
: 'The shared key is off, so it cannot report until an admin approves its new request.'
|
|
})
|
|
|
|
function openRevoke(row) {
|
|
revoking.value = { assetid: row.assetid, hostname: row.hostname, block: false, reason: '' }
|
|
}
|
|
|
|
async function confirmRevoke() {
|
|
const current = revoking.value
|
|
if (!current) return
|
|
busy.value = true
|
|
try {
|
|
const url = `/endpoints/enrollment/assets/${current.assetid}/revoke`
|
|
if (current.block) {
|
|
const body = { blockreenroll: true }
|
|
const reason = (current.reason || '').trim()
|
|
if (reason) body.reason = reason
|
|
await api.post(url, body)
|
|
} else {
|
|
await api.post(url)
|
|
}
|
|
toast.success(`Un-enrolled ${current.hostname}`)
|
|
revoking.value = null
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not un-enroll'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function allowReenroll(row) {
|
|
if (!confirm(`Allow ${row.hostname} to enroll again?\n\n`
|
|
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
|
|
busy.value = true
|
|
try {
|
|
await api.post(`/endpoints/enrollment/assets/${row.assetid}/allow-reenroll`)
|
|
toast.success(`${row.hostname} may enroll again`)
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not allow re-enrollment'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
// ---- Shared -----------------------------------------------------------
|
|
|
|
function stateLabel(state) {
|
|
return {
|
|
enrolled: 'Enrolled',
|
|
notenrolled: 'Not enrolled',
|
|
pending: 'Pending',
|
|
revoked: 'Revoked',
|
|
upgrading: 'Upgrading',
|
|
ready: 'Ready',
|
|
blocked: 'Blocked',
|
|
notreporting: 'Not reporting'
|
|
}[state] || state || '-'
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-'
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
|
|
// Waves progress on their own; poll while this tab is open.
|
|
let pollTimer = null
|
|
function startPolling() {
|
|
stopPolling()
|
|
pollTimer = setInterval(() => {
|
|
if (tab.value === 'migration' && !busy.value) loadMigration({ quiet: true })
|
|
}, POLL_MS)
|
|
}
|
|
function stopPolling() {
|
|
if (pollTimer) clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
|
|
watch(tab, value => {
|
|
if (value === 'migration') loadMigration()
|
|
else loadEnrollment()
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadMigration()
|
|
loadEnrollment({ quiet: true })
|
|
startPolling()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
stopPolling()
|
|
clearTimeout(migrationSearchTimer)
|
|
clearTimeout(enrollmentSearchTimer)
|
|
clearTimeout(assetSearchTimer)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.enroll-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.enroll-tab {
|
|
padding: 0.6rem 1.25rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
font: inherit;
|
|
font-weight: 500;
|
|
color: var(--text-light);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.enroll-tab:hover { color: var(--text); }
|
|
|
|
.enroll-tab.active {
|
|
color: var(--primary);
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
|
|
.load-error {
|
|
padding: 0.75rem 0;
|
|
color: var(--danger);
|
|
}
|
|
|
|
.enroll-tiles {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.enroll-tile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
padding: 0.8rem 0.95rem;
|
|
font: inherit;
|
|
color: var(--text);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.enroll-tile.static { cursor: default; }
|
|
|
|
.enroll-tile:not(.static):hover,
|
|
.enroll-tile.on { border-color: var(--primary); }
|
|
|
|
.enroll-tile .n {
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.enroll-tile .l {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.enroll-tile.ok .n { color: var(--success-dark); }
|
|
.enroll-tile.warn .n { color: var(--warning-dark); }
|
|
.enroll-tile.bad .n { color: var(--danger); }
|
|
|
|
[data-theme="dark"] .enroll-tile.ok .n { color: var(--success); }
|
|
[data-theme="dark"] .enroll-tile.warn .n { color: var(--warning); }
|
|
|
|
.progress-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.progress-track {
|
|
flex: 1;
|
|
height: 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-track.small {
|
|
height: 6px;
|
|
min-width: 80px;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: var(--success);
|
|
}
|
|
|
|
.progress-text {
|
|
font-size: 0.9rem;
|
|
color: var(--text-light);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.enroll-filter { max-width: 200px; }
|
|
|
|
.bulk-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.9rem;
|
|
margin-bottom: 0.75rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
|
|
|
.select-cell { width: 1%; }
|
|
|
|
.empty-cell {
|
|
text-align: center;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.state-chip {
|
|
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);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.state-chip.enrolled,
|
|
.state-chip.ready { color: var(--success-dark); }
|
|
.state-chip.pending,
|
|
.state-chip.upgrading { color: var(--warning-dark); }
|
|
.state-chip.blocked,
|
|
.state-chip.revoked { color: var(--danger); }
|
|
|
|
[data-theme="dark"] .state-chip.enrolled,
|
|
[data-theme="dark"] .state-chip.ready { color: var(--success); }
|
|
[data-theme="dark"] .state-chip.pending,
|
|
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
|
|
|
|
.retry-hint {
|
|
display: block;
|
|
font-size: 0.8em;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.blockers {
|
|
font-size: 0.85rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.section-title { margin: 1.5rem 0 0.5rem; }
|
|
|
|
.checkbox-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.number-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.number-row input {
|
|
width: 6rem;
|
|
flex: none;
|
|
}
|
|
|
|
.action-cell {
|
|
white-space: nowrap;
|
|
text-align: right;
|
|
}
|
|
|
|
.action-cell .btn + .btn { margin-left: 0.35rem; }
|
|
|
|
.approve-form {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.approve-form .form-control { max-width: 260px; }
|
|
|
|
.block-chip { margin-left: 0.35rem; }
|
|
|
|
.revoke-row .form-control { max-width: 260px; }
|
|
|
|
.mono {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
}
|
|
</style>
|