Found in the Windows VM re-test. - A PC installed without the shared key got 401 from POST /api/endpoints/enroll while the shared key was allowed, so no request was recorded and no admin could approve it. Enroll now never refuses: a valid shared key only decides auto-issue on a clean match; no key or an invalid key creates or updates the one pending request for that hostname and serial. An approved request still issues only to its own nonce. - A PC blocked with "server certificate not trusted by this PC" could never be queued again after trust was fixed, because the blocker comes from its last failed upgrade. Naming the PC in a wave now queues it when trust is its only blocker, and the response lists it under "retried"; "next n" still skips it. The Migration table lets such a PC be selected. - counts.pending on GET /enrollment counts pending requests, including ones for unknown hostnames; counts.pendingcomputers keeps the per-computer figure.
1117 lines
36 KiB
Vue
1117 lines
36 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>
|
|
</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,
|
|
which is within 15 minutes. Only the PC that made this
|
|
request can collect it.
|
|
</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 enrollmentSettings = ref({ sharedkeyallowed: true, autoenroll: true })
|
|
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
|
|
}
|
|
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 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'))
|
|
}
|
|
}
|
|
|
|
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 api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
|
|
{ assetid: current.assetid })
|
|
toast.success(`Approved ${current.hostname}`)
|
|
approving.value = null
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not approve the request'))
|
|
} 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;
|
|
}
|
|
|
|
.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>
|