Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
1087 lines
34 KiB
Vue
1087 lines
34 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="row.state === 'ready'"
|
|
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(', ') || '-' }}</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'))
|
|
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 ready (queued by a wave, went offline).
|
|
const readyIds = new Set(readyRows.value.map(row => row.assetid))
|
|
selected.value = selected.value.filter(id => readyIds.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)`)
|
|
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); }
|
|
|
|
.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>
|