Initial
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<VCenterTraceModal
|
||||
:open="vcenterTraceOpen"
|
||||
:trace="vcenterTraceEntries"
|
||||
@close="vcenterTraceOpen = false"
|
||||
/>
|
||||
|
||||
<BaseModal
|
||||
@@ -936,6 +944,7 @@ import ScriptExplorer from './components/scripts/ScriptExplorer.vue';
|
||||
import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue';
|
||||
import VariableEditorModal from './components/scripts/VariableEditorModal.vue';
|
||||
import VCenterImportModal from './components/hosts/VCenterImportModal.vue';
|
||||
import VCenterTraceModal from './components/hosts/VCenterTraceModal.vue';
|
||||
import DashboardView from './views/DashboardView.vue';
|
||||
import ErrorView from './views/ErrorView.vue';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
@@ -982,6 +991,8 @@ const vcenterImportError = ref('');
|
||||
const vcenterPreviewRows = ref([]);
|
||||
const vcenterImportStatus = ref(null);
|
||||
const vcenterImportDiagnostics = ref(null);
|
||||
const vcenterTraceEntries = ref([]);
|
||||
const vcenterTraceOpen = ref(false);
|
||||
const credentialModalOpen = ref(false);
|
||||
const runPlanModalOpen = ref(false);
|
||||
const variableModalOpen = ref(false);
|
||||
@@ -2352,6 +2363,8 @@ async function openVCenterImport() {
|
||||
vcenterImportOpen.value = true;
|
||||
vcenterImportError.value = '';
|
||||
vcenterImportDiagnostics.value = null;
|
||||
vcenterTraceEntries.value = [];
|
||||
vcenterTraceOpen.value = false;
|
||||
try {
|
||||
vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
|
||||
} catch (err) {
|
||||
@@ -2366,9 +2379,13 @@ async function previewVCenterImport(payload) {
|
||||
const result = await api.post('/api/hosts/import/vcenter/preview', payload);
|
||||
vcenterImportStatus.value = result.status;
|
||||
vcenterImportDiagnostics.value = result.diagnostics || null;
|
||||
vcenterTraceEntries.value = result.trace || [];
|
||||
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)`);
|
||||
} catch (err) {
|
||||
vcenterTraceEntries.value = err.payload?.trace || [];
|
||||
if (vcenterTraceEntries.value.length) vcenterTraceOpen.value = true;
|
||||
vcenterImportError.value = err.message;
|
||||
notify(err.message, 'error');
|
||||
} finally {
|
||||
@@ -2381,13 +2398,17 @@ async function importVCenterHosts(payload) {
|
||||
vcenterImportError.value = '';
|
||||
try {
|
||||
const result = await api.post('/api/hosts/import/vcenter', payload);
|
||||
vcenterTraceEntries.value = result.trace || vcenterTraceEntries.value;
|
||||
await refreshAll();
|
||||
vcenterPreviewRows.value = [];
|
||||
vcenterImportDiagnostics.value = null;
|
||||
vcenterTraceOpen.value = false;
|
||||
vcenterImportOpen.value = false;
|
||||
const skipped = result.skipped?.length || 0;
|
||||
notify(`Imported ${result.imported?.length || 0} host(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}`);
|
||||
} catch (err) {
|
||||
vcenterTraceEntries.value = err.payload?.trace || [];
|
||||
if (vcenterTraceEntries.value.length) vcenterTraceOpen.value = true;
|
||||
vcenterImportError.value = err.message;
|
||||
notify(err.message, 'error');
|
||||
} finally {
|
||||
|
||||
@@ -15,7 +15,12 @@ export function createApi(getToken, onUnauthorized) {
|
||||
if (response.status === 401) onUnauthorized?.();
|
||||
if (response.status === 204) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
<span :class="['status-dot', status.configured ? 'success' : 'warning']"></span>
|
||||
{{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }}
|
||||
</span>
|
||||
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
|
||||
<FileSearch :size="15" />View API trace
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -120,6 +123,9 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="preview-toolbar">
|
||||
@@ -174,7 +180,7 @@
|
||||
|
||||
<script setup>
|
||||
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';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -185,10 +191,11 @@ const props = defineProps({
|
||||
loading: Boolean,
|
||||
error: { type: String, default: '' },
|
||||
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({
|
||||
filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' },
|
||||
|
||||
85
client/src/components/hosts/VCenterTraceModal.vue
Normal file
85
client/src/components/hosts/VCenterTraceModal.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || [] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user