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

@@ -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 {

View File

@@ -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;
}

View File

@@ -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' },

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;
}
.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;
}