This commit is contained in:
2026-06-25 13:43:12 -05:00
parent dfc2c9b475
commit d8342a6475
9 changed files with 395 additions and 31 deletions

View File

@@ -535,6 +535,8 @@ VMware vCenter import uses the effective Config / Integrations values (`vcenter_
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. 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.
Each preview/import response also includes a redacted `trace` array. The wizard automatically opens a vCenter API trace modal after preview so operators can inspect the actual request URL, redacted headers, HTTP status, duration, response summary, and truncated response body from each vCenter call. Basic auth and `vmware-api-session-id` values are never shown in the trace.
Preview payload: Preview payload:
```json ```json

View File

@@ -426,9 +426,17 @@
:error="vcenterImportError" :error="vcenterImportError"
:status="vcenterImportStatus" :status="vcenterImportStatus"
:diagnostics="vcenterImportDiagnostics" :diagnostics="vcenterImportDiagnostics"
:trace-count="vcenterTraceEntries.length"
@close="vcenterImportOpen = false" @close="vcenterImportOpen = false"
@preview="previewVCenterImport" @preview="previewVCenterImport"
@import="importVCenterHosts" @import="importVCenterHosts"
@open-trace="vcenterTraceOpen = true"
/>
<VCenterTraceModal
:open="vcenterTraceOpen"
:trace="vcenterTraceEntries"
@close="vcenterTraceOpen = false"
/> />
<BaseModal <BaseModal
@@ -936,6 +944,7 @@ import ScriptExplorer from './components/scripts/ScriptExplorer.vue';
import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue'; import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue';
import VariableEditorModal from './components/scripts/VariableEditorModal.vue'; import VariableEditorModal from './components/scripts/VariableEditorModal.vue';
import VCenterImportModal from './components/hosts/VCenterImportModal.vue'; import VCenterImportModal from './components/hosts/VCenterImportModal.vue';
import VCenterTraceModal from './components/hosts/VCenterTraceModal.vue';
import DashboardView from './views/DashboardView.vue'; import DashboardView from './views/DashboardView.vue';
import ErrorView from './views/ErrorView.vue'; import ErrorView from './views/ErrorView.vue';
import LoginView from './views/LoginView.vue'; import LoginView from './views/LoginView.vue';
@@ -982,6 +991,8 @@ const vcenterImportError = ref('');
const vcenterPreviewRows = ref([]); const vcenterPreviewRows = ref([]);
const vcenterImportStatus = ref(null); const vcenterImportStatus = ref(null);
const vcenterImportDiagnostics = ref(null); const vcenterImportDiagnostics = ref(null);
const vcenterTraceEntries = ref([]);
const vcenterTraceOpen = ref(false);
const credentialModalOpen = ref(false); const credentialModalOpen = ref(false);
const runPlanModalOpen = ref(false); const runPlanModalOpen = ref(false);
const variableModalOpen = ref(false); const variableModalOpen = ref(false);
@@ -2352,6 +2363,8 @@ async function openVCenterImport() {
vcenterImportOpen.value = true; vcenterImportOpen.value = true;
vcenterImportError.value = ''; vcenterImportError.value = '';
vcenterImportDiagnostics.value = null; vcenterImportDiagnostics.value = null;
vcenterTraceEntries.value = [];
vcenterTraceOpen.value = false;
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) {
@@ -2366,9 +2379,13 @@ async function previewVCenterImport(payload) {
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; vcenterImportDiagnostics.value = result.diagnostics || null;
vcenterTraceEntries.value = result.trace || [];
vcenterPreviewRows.value = result.candidates || []; vcenterPreviewRows.value = result.candidates || [];
if (vcenterTraceEntries.value.length) vcenterTraceOpen.value = true;
notify(`Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`); notify(`Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`);
} catch (err) { } catch (err) {
vcenterTraceEntries.value = err.payload?.trace || [];
if (vcenterTraceEntries.value.length) vcenterTraceOpen.value = true;
vcenterImportError.value = err.message; vcenterImportError.value = err.message;
notify(err.message, 'error'); notify(err.message, 'error');
} finally { } finally {
@@ -2381,13 +2398,17 @@ async function importVCenterHosts(payload) {
vcenterImportError.value = ''; vcenterImportError.value = '';
try { try {
const result = await api.post('/api/hosts/import/vcenter', payload); const result = await api.post('/api/hosts/import/vcenter', payload);
vcenterTraceEntries.value = result.trace || vcenterTraceEntries.value;
await refreshAll(); await refreshAll();
vcenterPreviewRows.value = []; vcenterPreviewRows.value = [];
vcenterImportDiagnostics.value = null; vcenterImportDiagnostics.value = null;
vcenterTraceOpen.value = false;
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)` : ''}`);
} catch (err) { } catch (err) {
vcenterTraceEntries.value = err.payload?.trace || [];
if (vcenterTraceEntries.value.length) vcenterTraceOpen.value = true;
vcenterImportError.value = err.message; vcenterImportError.value = err.message;
notify(err.message, 'error'); notify(err.message, 'error');
} finally { } finally {

View File

@@ -15,7 +15,12 @@ export function createApi(getToken, onUnauthorized) {
if (response.status === 401) onUnauthorized?.(); if (response.status === 401) onUnauthorized?.();
if (response.status === 204) return null; if (response.status === 204) return null;
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`); if (!response.ok) {
const error = new Error(data.error || `Request failed: ${response.status}`);
error.status = response.status;
error.payload = data;
throw error;
}
return data; return data;
} }

View File

@@ -52,6 +52,9 @@
<span :class="['status-dot', status.configured ? 'success' : 'warning']"></span> <span :class="['status-dot', status.configured ? 'success' : 'warning']"></span>
{{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }} {{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }}
</span> </span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />View API trace
</button>
</div> </div>
</section> </section>
@@ -120,6 +123,9 @@
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span> <span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span>
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span> <span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
<span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</span> <span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />Trace
</button>
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small> <small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
</div> </div>
<div class="preview-toolbar"> <div class="preview-toolbar">
@@ -174,7 +180,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { CheckSquare, DownloadCloud, Search } from '@lucide/vue'; import { CheckSquare, DownloadCloud, FileSearch, Search } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue'; import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({ const props = defineProps({
@@ -185,10 +191,11 @@ const props = defineProps({
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 } diagnostics: { type: Object, default: null },
traceCount: { type: Number, default: 0 }
}); });
const emit = defineEmits(['close', 'preview', 'import']); const emit = defineEmits(['close', 'preview', 'import', 'open-trace']);
const draft = reactive({ const draft = reactive({
filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' }, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' },

View File

@@ -0,0 +1,85 @@
<template>
<BaseModal
:open="open"
title="vCenter API trace"
eyebrow="REQUEST / RESPONSE"
description="Redacted HTTP requests and responses captured during the last vCenter preview/import call."
wide
@close="$emit('close')"
>
<section class="vcenter-trace-modal">
<div class="trace-toolbar">
<div>
<strong>{{ trace.length }} captured call{{ trace.length === 1 ? '' : 's' }}</strong>
<span>Authorization and session identifiers are redacted.</span>
</div>
<button class="ghost-button compact" type="button" :disabled="!trace.length" @click="copyTrace">
<Clipboard :size="15" />Copy JSON
</button>
</div>
<div v-if="!trace.length" class="wizard-empty">
Run a vCenter preview to capture API request and response details.
</div>
<article v-for="(entry, index) in trace" :key="index" class="trace-entry">
<template v-if="entry.truncated">
<div class="trace-entry-header warning">
<AlertTriangle :size="16" />
<strong>Trace truncated</strong>
</div>
<p>{{ entry.message }}</p>
</template>
<template v-else>
<div class="trace-entry-header">
<span class="trace-method">{{ entry.method || 'INFO' }}</span>
<strong :class="statusClass(entry.status)">{{ entry.status || 'network' }}</strong>
<small>{{ entry.durationMs ?? '-' }} ms</small>
</div>
<code class="trace-url">{{ entry.url }}</code>
<div class="trace-grid">
<div>
<h4>Request headers</h4>
<pre>{{ format(entry.requestHeaders) }}</pre>
</div>
<div>
<h4>Response summary</h4>
<pre>{{ format(entry.responseSummary || { networkError: entry.networkError, statusText: entry.statusText }) }}</pre>
</div>
</div>
<div>
<h4>Response body</h4>
<pre class="trace-body">{{ entry.responseBody || entry.networkError || 'No body returned.' }}</pre>
</div>
</template>
</article>
</section>
</BaseModal>
</template>
<script setup>
import { AlertTriangle, Clipboard } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
open: Boolean,
trace: { type: Array, default: () => [] }
});
defineEmits(['close']);
function format(value) {
return JSON.stringify(value || {}, null, 2);
}
function statusClass(status) {
if (!status) return 'status-warn';
if (status >= 200 && status < 300) return 'status-ok';
if (status === 404) return 'status-warn';
return 'status-error';
}
async function copyTrace() {
await navigator.clipboard?.writeText(JSON.stringify(props.trace, null, 2));
}
</script>

View File

@@ -7074,6 +7074,141 @@ input:read-only {
text-align: center; text-align: center;
} }
.vcenter-trace-modal {
display: grid;
gap: 12px;
max-height: min(74vh, 820px);
overflow: auto;
padding-right: 2px;
}
.trace-toolbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid rgba(139, 224, 255, .14);
border-radius: 16px;
background: rgba(6, 12, 28, .92);
backdrop-filter: blur(14px);
}
.trace-toolbar div {
display: grid;
gap: 3px;
}
.trace-toolbar strong {
color: rgba(255, 255, 255, .9);
}
.trace-toolbar span {
color: rgba(219, 227, 255, .58);
font-size: .78rem;
}
.trace-entry {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(139, 224, 255, .12);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .06), rgba(166, 112, 255, .035)),
rgba(3, 8, 18, .52);
}
.trace-entry-header {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.trace-entry-header.warning {
color: #f8c66a;
}
.trace-method,
.trace-entry-header strong,
.trace-entry-header small {
display: inline-flex;
align-items: center;
min-height: 25px;
padding: 4px 9px;
border-radius: 999px;
background: rgba(139, 224, 255, .1);
border: 1px solid rgba(139, 224, 255, .14);
font-size: .74rem;
text-transform: uppercase;
letter-spacing: .08em;
}
.trace-entry-header .status-ok {
color: #8ef5c0;
border-color: rgba(102, 242, 181, .24);
background: rgba(102, 242, 181, .08);
}
.trace-entry-header .status-warn {
color: #f8c66a;
border-color: rgba(248, 198, 106, .24);
background: rgba(248, 198, 106, .08);
}
.trace-entry-header .status-error {
color: #ff9aaa;
border-color: rgba(255, 124, 152, .28);
background: rgba(255, 65, 112, .08);
}
.trace-url {
display: block;
padding: 9px 10px;
border: 1px solid rgba(139, 224, 255, .1);
border-radius: 12px;
color: #99e8ff;
background: rgba(0, 0, 0, .22);
overflow-wrap: anywhere;
}
.trace-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.trace-entry h4 {
margin: 0 0 6px;
color: rgba(151, 221, 255, .78);
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
}
.trace-entry pre {
margin: 0;
max-height: 260px;
overflow: auto;
padding: 11px;
border: 1px solid rgba(139, 224, 255, .1);
border-radius: 12px;
color: rgba(231, 244, 255, .84);
background: rgba(0, 0, 0, .3);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: .74rem;
line-height: 1.45;
}
.trace-body {
max-height: 360px;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.deployment-section-body.three, .deployment-section-body.three,
.deployment-section-body.two, .deployment-section-body.two,
@@ -7082,7 +7217,8 @@ input:read-only {
.relationship-row, .relationship-row,
.intune-deployment-form .installer-analyze-row, .intune-deployment-form .installer-analyze-row,
.vcenter-filter-grid, .vcenter-filter-grid,
.vcenter-options-grid { .vcenter-options-grid,
.trace-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -33,7 +33,7 @@ export async function previewVCenterImport(req, res) {
try { try {
res.json(await previewVCenterHosts(parsed.data)); res.json(await previewVCenterHosts(parsed.data));
} catch (err) { } catch (err) {
res.status(502).json({ error: err.message }); res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
} }
} }
@@ -59,8 +59,8 @@ export async function importVCenterHosts(req, res) {
imported.push(createHost(hostPayload, req.user.id)); imported.push(createHost(hostPayload, req.user.id));
} }
res.status(201).json({ imported, skipped, scanned: preview.count }); res.status(201).json({ imported, skipped, scanned: preview.count, trace: preview.trace || [] });
} catch (err) { } catch (err) {
res.status(502).json({ error: err.message }); res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
} }
} }

View File

@@ -5,6 +5,17 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'
const WINDOWS_HINTS = ['windows', 'microsoft']; const WINDOWS_HINTS = ['windows', 'microsoft'];
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux']; const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
const MAX_TRACE_ENTRIES = 700;
const MAX_TRACE_BODY_CHARS = 6000;
export class VCenterTraceError extends Error {
constructor(message, trace = [], statusCode = 502) {
super(message);
this.name = 'VCenterTraceError';
this.trace = trace;
this.statusCode = statusCode;
}
}
export function getVCenterSettings() { export function getVCenterSettings() {
return { return {
@@ -53,22 +64,89 @@ async function parseVCenterResponse(response) {
} }
} }
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false } = {}) { function summarizePayload(payload) {
if (Array.isArray(payload)) return { type: 'array', count: payload.length };
if (Array.isArray(payload?.value)) return { type: 'value-array', count: payload.value.length };
if (payload && typeof payload === 'object') return { type: 'object', keys: Object.keys(payload).slice(0, 20) };
return { type: typeof payload };
}
function bodyPreview(payload, path) {
if (path === '/api/session') return '[redacted vCenter session id]';
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
const text = raw || '';
return text.length > MAX_TRACE_BODY_CHARS
? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)`
: text;
}
function pushTrace(trace, entry) {
if (!trace) return;
if (trace.length < MAX_TRACE_ENTRIES) {
trace.push(entry);
return;
}
if (!trace.some((item) => item.truncated)) {
trace.push({
truncated: true,
message: `vCenter trace limited to ${MAX_TRACE_ENTRIES} entries. Narrow the filter to capture fewer guest enrichment calls.`
});
}
}
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false, trace = null } = {}) {
const headers = { Accept: 'application/json' }; const headers = { Accept: 'application/json' };
if (sessionId) headers['vmware-api-session-id'] = sessionId; if (sessionId) headers['vmware-api-session-id'] = sessionId;
else headers.Authorization = `Basic ${Buffer.from(`${settings.username}:${settings.password}`).toString('base64')}`; else headers.Authorization = `Basic ${Buffer.from(`${settings.username}:${settings.password}`).toString('base64')}`;
const response = await fetch(`${settings.baseUrl}${path}`, { const startedAt = Date.now();
let response;
let payload = null;
try {
response = await fetch(`${settings.baseUrl}${path}`, {
method, method,
headers, headers,
dispatcher: dispatcherFor(settings) dispatcher: dispatcherFor(settings)
}); });
payload = await parseVCenterResponse(response);
} catch (err) {
pushTrace(trace, {
method,
url: `${settings.baseUrl}${path}`,
requestHeaders: {
Accept: headers.Accept,
Authorization: headers.Authorization ? '[redacted basic auth]' : undefined,
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
},
durationMs: Date.now() - startedAt,
networkError: err.message
});
throw new VCenterTraceError(`Unable to reach vCenter ${method} ${path}: ${err.message}`, trace);
}
pushTrace(trace, {
method,
url: `${settings.baseUrl}${path}`,
requestHeaders: {
Accept: headers.Accept,
Authorization: headers.Authorization ? '[redacted basic auth]' : undefined,
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
},
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
responseHeaders: {
'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || ''
},
responseSummary: summarizePayload(payload),
responseBody: bodyPreview(payload, path)
});
if (allow404 && response.status === 404) return null; if (allow404 && response.status === 404) return null;
const payload = await parseVCenterResponse(response);
if (!response.ok) { if (!response.ok) {
const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText; const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText;
throw new Error(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`); throw new VCenterTraceError(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`, trace, response.status);
} }
return payload; return payload;
} }
@@ -79,16 +157,16 @@ function unwrapList(payload) {
return []; return [];
} }
async function createSession(settings) { async function createSession(settings, trace = null) {
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST' }); const payload = await vcenterFetch(settings, '/api/session', { method: 'POST', trace });
const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id; const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
if (!sessionId) throw new Error('vCenter session authentication succeeded but no session id was returned.'); if (!sessionId) throw new VCenterTraceError('vCenter session authentication succeeded but no session id was returned.', trace);
return sessionId; return sessionId;
} }
async function optionalVCenterFetch(settings, path, sessionId) { async function optionalVCenterFetch(settings, path, sessionId, trace = null) {
try { try {
return await vcenterFetch(settings, path, { sessionId, allow404: true }); return await vcenterFetch(settings, path, { sessionId, allow404: true, trace });
} catch (err) { } catch (err) {
return { unavailable: true, reason: err.message }; return { unavailable: true, reason: err.message };
} }
@@ -189,8 +267,14 @@ export function vmMatchesFilter(candidate, filter = {}) {
} }
export function filterSummaryVms(vms = [], filter = {}) { export function filterSummaryVms(vms = [], filter = {}) {
if ((filter?.field || 'hostname') !== 'name') return vms; const field = filter?.field || 'hostname';
return vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), filter)); if (!['name', 'hostname'].includes(field)) return { vms, summaryMatched: vms.length, summaryFilterApplied: false, summaryFilterFallback: false };
const summaryFilter = { ...filter, field: 'name' };
const matched = vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), summaryFilter));
if (field === 'hostname' && !matched.length) {
return { vms, summaryMatched: 0, summaryFilterApplied: true, summaryFilterFallback: true };
}
return { vms: matched, summaryMatched: matched.length, summaryFilterApplied: true, summaryFilterFallback: false };
} }
export function buildHostPayloadFromVm(candidate, options = {}) { export function buildHostPayloadFromVm(candidate, options = {}) {
@@ -216,17 +300,25 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
} }
export async function previewVCenterHosts(options = {}) { export async function previewVCenterHosts(options = {}) {
const trace = [];
const settings = getVCenterSettings(); const settings = getVCenterSettings();
try {
assertConfigured(settings); assertConfigured(settings);
const sessionId = await createSession(settings); } catch (err) {
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId }); throw new VCenterTraceError(err.message, trace, 400);
}
const sessionId = await createSession(settings, trace);
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId, trace });
const allVms = unwrapList(vmPayload); const allVms = unwrapList(vmPayload);
const vms = filterSummaryVms(allVms, options.filter); const summaryFilter = filterSummaryVms(allVms, options.filter);
const vms = summaryFilter.vms;
const candidates = []; const candidates = [];
const diagnostics = { const diagnostics = {
totalFromVCenter: allVms.length, totalFromVCenter: allVms.length,
scanned: 0, scanned: 0,
summaryMatched: vms.length, summaryMatched: summaryFilter.summaryMatched,
summaryFilterApplied: summaryFilter.summaryFilterApplied,
summaryFilterFallback: summaryFilter.summaryFilterFallback,
resultLimit: options.limit || 100, resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean), sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
filter: options.filter || {} filter: options.filter || {}
@@ -236,8 +328,8 @@ export async function previewVCenterHosts(options = {}) {
const vmId = vm.vm || vm.id; const vmId = vm.vm || vm.id;
if (!vmId) continue; if (!vmId) continue;
diagnostics.scanned += 1; 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, trace);
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, trace);
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces); const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate); if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
if (candidates.length >= (options.limit || 100)) break; if (candidates.length >= (options.limit || 100)) break;
@@ -248,6 +340,7 @@ export async function previewVCenterHosts(options = {}) {
filter: options.filter || {}, filter: options.filter || {},
count: candidates.length, count: candidates.length,
diagnostics, diagnostics,
trace,
candidates candidates
}; };
} }

View File

@@ -43,10 +43,25 @@ test('filterSummaryVms scans the full VM summary list for VM-name filters', () =
...Array.from({ length: 900 }, (_, index) => ({ vm: `vm-${index}`, name: `ZZZ-${index}` })), ...Array.from({ length: 900 }, (_, index) => ({ vm: `vm-${index}`, name: `ZZZ-${index}` })),
{ vm: 'vm-901', name: 'MCS-WIN-APP01' } { vm: 'vm-901', name: 'MCS-WIN-APP01' }
]; ];
const matched = filterSummaryVms(vms, { field: 'name', operator: 'contains', value: 'MCS' }); const result = filterSummaryVms(vms, { field: 'name', operator: 'contains', value: 'MCS' });
assert.equal(matched.length, 1); assert.equal(result.summaryFilterApplied, true);
assert.equal(matched[0].name, 'MCS-WIN-APP01'); assert.equal(result.summaryMatched, 1);
assert.equal(result.vms.length, 1);
assert.equal(result.vms[0].name, 'MCS-WIN-APP01');
});
test('filterSummaryVms prefilters hostname searches by VM name and falls back when needed', () => {
const vms = [{ vm: 'vm-1', name: 'MCS-WIN-APP01' }, { vm: 'vm-2', name: 'SQL-01' }];
const matched = filterSummaryVms(vms, { field: 'hostname', operator: 'contains', value: 'MCS' });
const fallback = filterSummaryVms(vms, { field: 'hostname', operator: 'contains', value: 'guest-only-name' });
assert.equal(matched.summaryMatched, 1);
assert.equal(matched.summaryFilterFallback, false);
assert.deepEqual(matched.vms.map((vm) => vm.vm), ['vm-1']);
assert.equal(fallback.summaryMatched, 0);
assert.equal(fallback.summaryFilterFallback, true);
assert.equal(fallback.vms.length, 2);
}); });
test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => { test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => {