({}));
- 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;
}
diff --git a/client/src/components/hosts/VCenterImportModal.vue b/client/src/components/hosts/VCenterImportModal.vue
index d3e2e47..9b1a8a2 100644
--- a/client/src/components/hosts/VCenterImportModal.vue
+++ b/client/src/components/hosts/VCenterImportModal.vue
@@ -52,6 +52,9 @@
{{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }}
+
+ View API trace
+
@@ -120,6 +123,9 @@
{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter
{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment
{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details
+
+ Trace
+
Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}
@@ -174,7 +180,7 @@
diff --git a/client/src/style.css b/client/src/style.css
index f6826b2..4cee54f 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -7074,6 +7074,141 @@ input:read-only {
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) {
.deployment-section-body.three,
.deployment-section-body.two,
@@ -7082,7 +7217,8 @@ input:read-only {
.relationship-row,
.intune-deployment-form .installer-analyze-row,
.vcenter-filter-grid,
- .vcenter-options-grid {
+ .vcenter-options-grid,
+ .trace-grid {
grid-template-columns: 1fr;
}
diff --git a/server/controllers/hostController.js b/server/controllers/hostController.js
index c6a1db2..dd043c9 100644
--- a/server/controllers/hostController.js
+++ b/server/controllers/hostController.js
@@ -33,7 +33,7 @@ export async function previewVCenterImport(req, res) {
try {
res.json(await previewVCenterHosts(parsed.data));
} 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));
}
- res.status(201).json({ imported, skipped, scanned: preview.count });
+ res.status(201).json({ imported, skipped, scanned: preview.count, trace: preview.trace || [] });
} catch (err) {
- res.status(502).json({ error: err.message });
+ res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
}
}
diff --git a/server/services/vcenterService.js b/server/services/vcenterService.js
index 8caa22f..e18e679 100644
--- a/server/services/vcenterService.js
+++ b/server/services/vcenterService.js
@@ -5,6 +5,17 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'
const WINDOWS_HINTS = ['windows', 'microsoft'];
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() {
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' };
if (sessionId) headers['vmware-api-session-id'] = sessionId;
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,
+ headers,
+ 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,
- headers,
- dispatcher: dispatcherFor(settings)
+ 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;
- const payload = await parseVCenterResponse(response);
if (!response.ok) {
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;
}
@@ -79,16 +157,16 @@ function unwrapList(payload) {
return [];
}
-async function createSession(settings) {
- const payload = await vcenterFetch(settings, '/api/session', { method: 'POST' });
+async function createSession(settings, trace = null) {
+ const payload = await vcenterFetch(settings, '/api/session', { method: 'POST', trace });
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;
}
-async function optionalVCenterFetch(settings, path, sessionId) {
+async function optionalVCenterFetch(settings, path, sessionId, trace = null) {
try {
- return await vcenterFetch(settings, path, { sessionId, allow404: true });
+ return await vcenterFetch(settings, path, { sessionId, allow404: true, trace });
} catch (err) {
return { unavailable: true, reason: err.message };
}
@@ -189,8 +267,14 @@ export function vmMatchesFilter(candidate, filter = {}) {
}
export function filterSummaryVms(vms = [], filter = {}) {
- if ((filter?.field || 'hostname') !== 'name') return vms;
- return vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), filter));
+ const field = filter?.field || 'hostname';
+ 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 = {}) {
@@ -216,17 +300,25 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
}
export async function previewVCenterHosts(options = {}) {
+ const trace = [];
const settings = getVCenterSettings();
- assertConfigured(settings);
- const sessionId = await createSession(settings);
- const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId });
+ try {
+ assertConfigured(settings);
+ } catch (err) {
+ 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 vms = filterSummaryVms(allVms, options.filter);
+ const summaryFilter = filterSummaryVms(allVms, options.filter);
+ const vms = summaryFilter.vms;
const candidates = [];
const diagnostics = {
totalFromVCenter: allVms.length,
scanned: 0,
- summaryMatched: vms.length,
+ summaryMatched: summaryFilter.summaryMatched,
+ summaryFilterApplied: summaryFilter.summaryFilterApplied,
+ summaryFilterFallback: summaryFilter.summaryFilterFallback,
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
filter: options.filter || {}
@@ -236,8 +328,8 @@ export async function previewVCenterHosts(options = {}) {
const vmId = vm.vm || vm.id;
if (!vmId) continue;
diagnostics.scanned += 1;
- 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 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, trace);
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
if (candidates.length >= (options.limit || 100)) break;
@@ -248,6 +340,7 @@ export async function previewVCenterHosts(options = {}) {
filter: options.filter || {},
count: candidates.length,
diagnostics,
+ trace,
candidates
};
}
diff --git a/server/test/vcenter.test.mjs b/server/test/vcenter.test.mjs
index 992c2b6..5442dad 100644
--- a/server/test/vcenter.test.mjs
+++ b/server/test/vcenter.test.mjs
@@ -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}` })),
{ 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(matched[0].name, 'MCS-WIN-APP01');
+ assert.equal(result.summaryFilterApplied, true);
+ 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', () => {