diff --git a/README.md b/README.md index a80340d..2a5f6b4 100644 --- a/README.md +++ b/README.md @@ -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. +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: ```json diff --git a/client/src/App.vue b/client/src/App.vue index 8cb0e2b..fd88f18 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -426,9 +426,17 @@ :error="vcenterImportError" :status="vcenterImportStatus" :diagnostics="vcenterImportDiagnostics" + :trace-count="vcenterTraceEntries.length" @close="vcenterImportOpen = false" @preview="previewVCenterImport" @import="importVCenterHosts" + @open-trace="vcenterTraceOpen = true" + /> + + ({})); - 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' }} + @@ -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 + 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', () => {