This commit is contained in:
2026-06-25 13:35:50 -05:00
parent ff94c0cce5
commit dfc2c9b475
6 changed files with 96 additions and 3 deletions

View File

@@ -533,6 +533,8 @@ target hosts visible to the operator who creates or edits them.
VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). The backend creates a vCenter REST session with `POST /api/session`, lists VMs from `/api/vcenter/vm`, and then best-effort enriches each candidate from guest identity/networking endpoints. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes. VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). The backend creates a vCenter REST session with `POST /api/session`, lists VMs from `/api/vcenter/vm`, and then best-effort enriches each candidate from guest identity/networking endpoints. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes.
For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview.
Preview payload: Preview payload:
```json ```json

View File

@@ -425,6 +425,7 @@
:loading="vcenterImportLoading" :loading="vcenterImportLoading"
:error="vcenterImportError" :error="vcenterImportError"
:status="vcenterImportStatus" :status="vcenterImportStatus"
:diagnostics="vcenterImportDiagnostics"
@close="vcenterImportOpen = false" @close="vcenterImportOpen = false"
@preview="previewVCenterImport" @preview="previewVCenterImport"
@import="importVCenterHosts" @import="importVCenterHosts"
@@ -980,6 +981,7 @@ const vcenterImportLoading = ref(false);
const vcenterImportError = ref(''); const vcenterImportError = ref('');
const vcenterPreviewRows = ref([]); const vcenterPreviewRows = ref([]);
const vcenterImportStatus = ref(null); const vcenterImportStatus = ref(null);
const vcenterImportDiagnostics = ref(null);
const credentialModalOpen = ref(false); const credentialModalOpen = ref(false);
const runPlanModalOpen = ref(false); const runPlanModalOpen = ref(false);
const variableModalOpen = ref(false); const variableModalOpen = ref(false);
@@ -2349,6 +2351,7 @@ async function saveHost() {
async function openVCenterImport() { async function openVCenterImport() {
vcenterImportOpen.value = true; vcenterImportOpen.value = true;
vcenterImportError.value = ''; vcenterImportError.value = '';
vcenterImportDiagnostics.value = null;
try { try {
vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status'); vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
} catch (err) { } catch (err) {
@@ -2362,8 +2365,9 @@ async function previewVCenterImport(payload) {
try { try {
const result = await api.post('/api/hosts/import/vcenter/preview', payload); const result = await api.post('/api/hosts/import/vcenter/preview', payload);
vcenterImportStatus.value = result.status; vcenterImportStatus.value = result.status;
vcenterImportDiagnostics.value = result.diagnostics || null;
vcenterPreviewRows.value = result.candidates || []; vcenterPreviewRows.value = result.candidates || [];
notify(`Found ${result.count || 0} vCenter host candidate(s)`); notify(`Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`);
} catch (err) { } catch (err) {
vcenterImportError.value = err.message; vcenterImportError.value = err.message;
notify(err.message, 'error'); notify(err.message, 'error');
@@ -2379,6 +2383,7 @@ async function importVCenterHosts(payload) {
const result = await api.post('/api/hosts/import/vcenter', payload); const result = await api.post('/api/hosts/import/vcenter', payload);
await refreshAll(); await refreshAll();
vcenterPreviewRows.value = []; vcenterPreviewRows.value = [];
vcenterImportDiagnostics.value = null;
vcenterImportOpen.value = false; vcenterImportOpen.value = false;
const skipped = result.skipped?.length || 0; const skipped = result.skipped?.length || 0;
notify(`Imported ${result.imported?.length || 0} host(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}`); notify(`Imported ${result.imported?.length || 0} host(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}`);

View File

@@ -115,6 +115,13 @@
</div> </div>
</div> </div>
<div v-if="error" class="inline-error">{{ error }}</div> <div v-if="error" class="inline-error">{{ error }}</div>
<div v-if="diagnostics" class="vcenter-diagnostics">
<strong>Preview diagnostics</strong>
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span>
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
<span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</span>
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
</div>
<div class="preview-toolbar"> <div class="preview-toolbar">
<button class="ghost-button compact" type="button" :disabled="!previewRows.length" @click="toggleAll"> <button class="ghost-button compact" type="button" :disabled="!previewRows.length" @click="toggleAll">
<CheckSquare :size="15" />{{ allSelected ? 'Clear selection' : 'Select all' }} <CheckSquare :size="15" />{{ allSelected ? 'Clear selection' : 'Select all' }}
@@ -177,7 +184,8 @@ const props = defineProps({
groups: { type: Array, default: () => [] }, groups: { type: Array, default: () => [] },
loading: Boolean, loading: Boolean,
error: { type: String, default: '' }, error: { type: String, default: '' },
status: { type: Object, default: null } status: { type: Object, default: null },
diagnostics: { type: Object, default: null }
}); });
const emit = defineEmits(['close', 'preview', 'import']); const emit = defineEmits(['close', 'preview', 'import']);

View File

@@ -6981,6 +6981,40 @@ input:read-only {
margin-bottom: 12px; margin-bottom: 12px;
} }
.vcenter-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid rgba(117, 216, 255, .16);
border-radius: 14px;
color: rgba(219, 227, 255, .7);
background: rgba(88, 221, 255, .055);
}
.vcenter-diagnostics strong {
color: rgba(255, 255, 255, .9);
}
.vcenter-diagnostics span {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(139, 224, 255, .14);
background: rgba(3, 8, 18, .35);
font-size: .76rem;
}
.vcenter-diagnostics small {
flex-basis: 100%;
color: rgba(219, 227, 255, .52);
overflow-wrap: anywhere;
}
.vcenter-preview-table { .vcenter-preview-table {
position: relative; position: relative;
overflow: auto; overflow: auto;

View File

@@ -146,6 +146,22 @@ export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null
}; };
} }
function normalizeVCenterSummaryVm(vm) {
return {
id: vm.vm || vm.id,
name: vm.name || vm.vm || vm.id || '',
fqdn: '',
address: vm.name || vm.vm || vm.id || '',
ipAddress: '',
ipAddresses: [],
powerState: vm.power_state || vm.powerState || '',
osFamily: 'windows',
guestFullName: '',
toolsAvailable: false,
source: 'vcenter'
};
}
export function vmMatchesFilter(candidate, filter = {}) { export function vmMatchesFilter(candidate, filter = {}) {
const operator = filter.operator || 'contains'; const operator = filter.operator || 'contains';
const needle = String(filter.value || '').trim().toLowerCase(); const needle = String(filter.value || '').trim().toLowerCase();
@@ -172,6 +188,11 @@ export function vmMatchesFilter(candidate, filter = {}) {
return value.includes(needle); return value.includes(needle);
} }
export function filterSummaryVms(vms = [], filter = {}) {
if ((filter?.field || 'hostname') !== 'name') return vms;
return vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), filter));
}
export function buildHostPayloadFromVm(candidate, options = {}) { export function buildHostPayloadFromVm(candidate, options = {}) {
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))]; const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
return { return {
@@ -199,12 +220,22 @@ export async function previewVCenterHosts(options = {}) {
assertConfigured(settings); assertConfigured(settings);
const sessionId = await createSession(settings); const sessionId = await createSession(settings);
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId }); const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId });
const vms = unwrapList(vmPayload).slice(0, Math.max(options.limit || 100, 1) * 4); const allVms = unwrapList(vmPayload);
const vms = filterSummaryVms(allVms, options.filter);
const candidates = []; const candidates = [];
const diagnostics = {
totalFromVCenter: allVms.length,
scanned: 0,
summaryMatched: vms.length,
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
filter: options.filter || {}
};
for (const vm of vms) { for (const vm of vms) {
const vmId = vm.vm || vm.id; const vmId = vm.vm || vm.id;
if (!vmId) continue; if (!vmId) continue;
diagnostics.scanned += 1;
const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId); const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId);
const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId); const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId);
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces); const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
@@ -216,6 +247,7 @@ export async function previewVCenterHosts(options = {}) {
status: vcenterStatus(), status: vcenterStatus(),
filter: options.filter || {}, filter: options.filter || {},
count: candidates.length, count: candidates.length,
diagnostics,
candidates candidates
}; };
} }

View File

@@ -5,6 +5,7 @@ import { load } from './setup.mjs';
const { const {
buildHostPayloadFromVm, buildHostPayloadFromVm,
filterSummaryVms,
normalizeBaseUrl, normalizeBaseUrl,
normalizeVCenterVm, normalizeVCenterVm,
vmMatchesFilter vmMatchesFilter
@@ -37,6 +38,17 @@ test('vmMatchesFilter supports hostname contains and IP equals matching', () =>
assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false); assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false);
}); });
test('filterSummaryVms scans the full VM summary list for VM-name filters', () => {
const vms = [
...Array.from({ length: 900 }, (_, index) => ({ vm: `vm-${index}`, name: `ZZZ-${index}` })),
{ vm: 'vm-901', name: 'MCS-WIN-APP01' }
];
const matched = filterSummaryVms(vms, { field: 'name', operator: 'contains', value: 'MCS' });
assert.equal(matched.length, 1);
assert.equal(matched[0].name, 'MCS-WIN-APP01');
});
test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => { test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => {
const payload = buildHostPayloadFromVm( const payload = buildHostPayloadFromVm(
{ {