This commit is contained in:
2026-06-25 13:20:58 -05:00
parent 7b622fb2fd
commit ff94c0cce5
16 changed files with 968 additions and 8 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ tools/*.exe
PLAN*.MD PLAN*.MD
PLAN-INTUNE.md PLAN-INTUNE.md
project.txt project.txt
*-lock.json

View File

@@ -29,7 +29,7 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/
- PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata. - PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata.
- Intune Publishing Wizard for PSADT Win32 deployment plans, including package source, `.intunewin` tracking, command style, UI compatibility, requirements, detection rules, return-code policy, assignment rings, status, Graph app ID, and visibility. - Intune Publishing Wizard for PSADT Win32 deployment plans, including package source, `.intunewin` tracking, command style, UI compatibility, requirements, detection rules, return-code policy, assignment rings, status, Graph app ID, and visibility.
- Microsoft Graph Intune tracking for PSADT deployments, with Graph environment configuration managed under Config / Intune plus mobile app linking, status sync, failure review, device/user status rows, and sync run history. - Microsoft Graph Intune tracking for PSADT deployments, with Graph environment configuration managed under Config / Intune plus mobile app linking, status sync, failure review, device/user status rows, and sync run history.
- Host Library with searchable/sortable/paginated table and modal add/edit flow. - Host Library with searchable/sortable/paginated table, modal add/edit flow, and VMware vCenter VM import wizard with preview filters and credential attachment.
- Credential Vault as its own menu item with encrypted-at-rest secrets and modal add/edit flow. - Credential Vault as its own menu item with encrypted-at-rest secrets and modal add/edit flow.
- RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action. - RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action.
- Aggregate job log viewer with search/filter/pagination. - Aggregate job log viewer with search/filter/pagination.
@@ -158,6 +158,11 @@ Important environment variables:
| `DEFAULT_ADMIN_NAME` | First-run admin display name. | | `DEFAULT_ADMIN_NAME` | First-run admin display name. |
| `POWERSHELL_BIN` | PowerShell executable. Defaults to `pwsh`. | | `POWERSHELL_BIN` | PowerShell executable. Defaults to `pwsh`. |
| `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. | | `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. |
| `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. |
| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. |
| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
| `VCENTER_PASSWORD` | vCenter password. Prefer environment pinning for production deployments. |
| `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. |
| `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. | | `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. |
| `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. | | `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. |
| `TOOLS_DIR` | Directory for bundled tools. Defaults to `./tools`. | | `TOOLS_DIR` | Directory for bundled tools. Defaults to `./tools`. |
@@ -498,6 +503,9 @@ Payload:
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET` | `/api/hosts` | User | List host library. | | `GET` | `/api/hosts` | User | List host library. |
| `POST` | `/api/hosts` | User | Create host. | | `POST` | `/api/hosts` | User | Create host. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return effective vCenter import configuration status without the password. |
| `POST` | `/api/hosts/import/vcenter/preview` | User | Authenticate to vCenter, search VM inventory, and return import candidates. |
| `POST` | `/api/hosts/import/vcenter` | User | Import selected vCenter VM candidates as hosts, attaching the chosen credential/defaults. |
| `PUT` | `/api/hosts/:id` | User | Update host. | | `PUT` | `/api/hosts/:id` | User | Update host. |
| `DELETE` | `/api/hosts/:id` | User | Delete host. | | `DELETE` | `/api/hosts/:id` | User | Delete host. |
@@ -523,6 +531,47 @@ Payload:
`personal`, `shared`, or `group`; hosts default to `shared`. RunPlans can only `personal`, `shared`, or `group`; hosts default to `shared`. RunPlans can only
target hosts visible to the operator who creates or edits them. target hosts visible to the operator who creates or edits them.
VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). The backend creates a vCenter REST session with `POST /api/session`, lists VMs from `/api/vcenter/vm`, and then best-effort enriches each candidate from guest identity/networking endpoints. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes.
Preview payload:
```json
{
"filter": {
"field": "hostname",
"operator": "contains",
"value": "ENG-ENT"
},
"limit": 100
}
```
Import payload:
```json
{
"filter": {
"field": "hostname",
"operator": "contains",
"value": "ENG-ENT"
},
"limit": 100,
"vmIds": ["vm-101", "vm-205"],
"credentialId": "cred_...",
"transport": "winrm",
"port": null,
"tags": ["engineering", "vcenter-import"],
"visibility": "group",
"groupId": "grp_..."
}
```
`field` can be `hostname`, `name`, `fqdn`, or `ip`. `operator` can be
`contains`, `equals`, `startsWith`, or `endsWith`. If `vmIds` is empty, all
previewed candidates that match the filter are considered. Existing visible
hosts with the same name, address, or FQDN are skipped and returned in the
`skipped` array so imports can be rerun safely.
### Script Library ### Script Library
| Method | Route | Auth | Description | | Method | Route | Auth | Description |

View File

@@ -304,6 +304,9 @@
<p>Search, sort, and attach credentials to the hosts used by RunPlans.</p> <p>Search, sort, and attach credentials to the hosts used by RunPlans.</p>
</div> </div>
<div class="page-actions"> <div class="page-actions">
<button class="ghost-button compact" type="button" @click="openVCenterImport">
<Download :size="16" />Import from vCenter
</button>
<button class="primary-action compact" type="button" @click="openHostModal()"><Plus :size="16" />Add host</button> <button class="primary-action compact" type="button" @click="openHostModal()"><Plus :size="16" />Add host</button>
</div> </div>
</div> </div>
@@ -414,6 +417,19 @@
</ResourceTable> </ResourceTable>
</section> </section>
<VCenterImportModal
:open="vcenterImportOpen"
:preview-rows="vcenterPreviewRows"
:credentials="credentials"
:groups="groups"
:loading="vcenterImportLoading"
:error="vcenterImportError"
:status="vcenterImportStatus"
@close="vcenterImportOpen = false"
@preview="previewVCenterImport"
@import="importVCenterHosts"
/>
<BaseModal <BaseModal
:open="hostModalOpen" :open="hostModalOpen"
:title="hostForm.id ? 'Update host' : 'Add host'" :title="hostForm.id ? 'Update host' : 'Add host'"
@@ -574,6 +590,7 @@
:label-for="settingLabel" :label-for="settingLabel"
:description-for="settingDescription" :description-for="settingDescription"
:placeholder-for="settingPlaceholder" :placeholder-for="settingPlaceholder"
:is-secret-setting="isSecretSetting"
:source-label="settingSourceLabel" :source-label="settingSourceLabel"
:is-boolean-setting="isBooleanSetting" :is-boolean-setting="isBooleanSetting"
:normalized-boolean-setting="normalizedBooleanSetting" :normalized-boolean-setting="normalizedBooleanSetting"
@@ -917,6 +934,7 @@ import ResourceTable from './components/ui/ResourceTable.vue';
import ScriptExplorer from './components/scripts/ScriptExplorer.vue'; 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 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';
@@ -957,6 +975,11 @@ const scriptTestError = ref('');
const userModalOpen = ref(false); const userModalOpen = ref(false);
const groupModalOpen = ref(false); const groupModalOpen = ref(false);
const hostModalOpen = ref(false); const hostModalOpen = ref(false);
const vcenterImportOpen = ref(false);
const vcenterImportLoading = ref(false);
const vcenterImportError = ref('');
const vcenterPreviewRows = ref([]);
const vcenterImportStatus = ref(null);
const credentialModalOpen = ref(false); const credentialModalOpen = ref(false);
const runPlanModalOpen = ref(false); const runPlanModalOpen = ref(false);
const variableModalOpen = ref(false); const variableModalOpen = ref(false);
@@ -1075,6 +1098,35 @@ const settingMetadata = {
label: 'Script Execution', label: 'Script Execution',
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.', description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
section: 'Execution' section: 'Execution'
},
vcenter_enabled: {
label: 'VMware vCenter',
description: 'Enable VM discovery and import through the VMware vCenter REST API.',
section: 'Integrations'
},
vcenter_base_url: {
label: 'vCenter Base URL',
description: 'Root URL of the vCenter API endpoint used for VM inventory discovery.',
placeholder: 'https://vcenter.contoso.local',
section: 'Integrations'
},
vcenter_username: {
label: 'vCenter Username',
description: 'Account used to create vCenter API sessions for host import previews.',
placeholder: 'administrator@vsphere.local',
section: 'Integrations'
},
vcenter_password: {
label: 'vCenter Password',
description: 'Password used only by the backend service when authenticating to vCenter.',
placeholder: 'Stored in app settings or pinned by VCENTER_PASSWORD',
section: 'Integrations',
secret: true
},
vcenter_allow_untrusted_tls: {
label: 'Allow Untrusted vCenter TLS',
description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.',
section: 'Integrations'
} }
}; };
const users = ref([]); const users = ref([]);
@@ -1282,7 +1334,7 @@ const dashboardSettingsPreview = computed(() => ['server_fqdn', 'trusted_origins
.filter((key) => settings.value[key]) .filter((key) => settings.value[key])
.map((key) => ({ key, ...settings.value[key] }))); .map((key) => ({ key, ...settings.value[key] })));
const configSections = computed(() => { const configSections = computed(() => {
const sections = ['Deployment', 'Authentication', 'Execution', 'Runtime', 'Other']; const sections = ['Deployment', 'Authentication', 'Integrations', 'Execution', 'Runtime', 'Other'];
return sections return sections
.map((section) => ({ .map((section) => ({
section, section,
@@ -2294,6 +2346,50 @@ async function saveHost() {
notify('Host saved'); notify('Host saved');
} }
async function openVCenterImport() {
vcenterImportOpen.value = true;
vcenterImportError.value = '';
try {
vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
} catch (err) {
vcenterImportError.value = err.message;
}
}
async function previewVCenterImport(payload) {
vcenterImportLoading.value = true;
vcenterImportError.value = '';
try {
const result = await api.post('/api/hosts/import/vcenter/preview', payload);
vcenterImportStatus.value = result.status;
vcenterPreviewRows.value = result.candidates || [];
notify(`Found ${result.count || 0} vCenter host candidate(s)`);
} catch (err) {
vcenterImportError.value = err.message;
notify(err.message, 'error');
} finally {
vcenterImportLoading.value = false;
}
}
async function importVCenterHosts(payload) {
vcenterImportLoading.value = true;
vcenterImportError.value = '';
try {
const result = await api.post('/api/hosts/import/vcenter', payload);
await refreshAll();
vcenterPreviewRows.value = [];
vcenterImportOpen.value = false;
const skipped = result.skipped?.length || 0;
notify(`Imported ${result.imported?.length || 0} host(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}`);
} catch (err) {
vcenterImportError.value = err.message;
notify(err.message, 'error');
} finally {
vcenterImportLoading.value = false;
}
}
function resetHostForm() { function resetHostForm() {
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null }); Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null });
} }
@@ -2709,6 +2805,10 @@ function settingPlaceholder(key) {
return settingMetadata[key]?.placeholder || ''; return settingMetadata[key]?.placeholder || '';
} }
function isSecretSetting(key) {
return Boolean(settingMetadata[key]?.secret || /(?:password|secret|token|key)$/i.test(key));
}
function settingSourceLabel(source) { function settingSourceLabel(source) {
return source === 'env' ? 'ENV' : 'DB'; return source === 'env' ? 'ENV' : 'DB';
} }
@@ -2717,6 +2817,7 @@ function configSectionMeta(section) {
const meta = { const meta = {
Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' }, Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' },
Authentication: { icon: ShieldCheck, description: 'Identity provider and password-flow configuration.' }, Authentication: { icon: ShieldCheck, description: 'Identity provider and password-flow configuration.' },
Integrations: { icon: Download, description: 'External systems used for inventory, publishing, and governance workflows.' },
Execution: { icon: PlayCircle, description: 'PowerShell execution guardrails.' }, Execution: { icon: PlayCircle, description: 'PowerShell execution guardrails.' },
Runtime: { icon: Database, description: 'Container runtime paths and API process settings.' }, Runtime: { icon: Database, description: 'Container runtime paths and API process settings.' },
Other: { icon: Settings, description: 'Additional app settings exposed by the API.' } Other: { icon: Settings, description: 'Additional app settings exposed by the API.' }

View File

@@ -0,0 +1,240 @@
<template>
<BaseModal
:open="open"
title="Import hosts from vCenter"
eyebrow="VMWARE VCENTER"
description="Search vCenter virtual machines, preview discovered identity data, and create managed hosts with one shared vault credential."
wide
@close="$emit('close')"
>
<form class="vcenter-import-form" @submit.prevent="submitImport">
<section class="wizard-section">
<div class="section-heading">
<span>1</span>
<div>
<h4>Search filter</h4>
<p>Use vCenter VM names, guest hostnames, FQDNs, or IPs to narrow the import set before writing hosts.</p>
</div>
</div>
<div class="vcenter-filter-grid">
<label>
<span>Field</span>
<select v-model="draft.filter.field">
<option value="hostname">Hostname / VM name</option>
<option value="name">VM name only</option>
<option value="fqdn">Guest FQDN</option>
<option value="ip">IP address</option>
</select>
</label>
<label>
<span>Operator</span>
<select v-model="draft.filter.operator">
<option value="contains">Contains</option>
<option value="equals">Equals</option>
<option value="startsWith">Starts with</option>
<option value="endsWith">Ends with</option>
</select>
</label>
<label class="filter-value">
<span>Value</span>
<input v-model="draft.filter.value" placeholder="ENG-ENT" />
</label>
<label>
<span>Preview limit</span>
<input v-model.number="draft.limit" type="number" min="1" max="500" />
</label>
</div>
<div class="wizard-actions">
<button class="ghost-button" type="button" :disabled="loading" @click="emitPreview">
<Search :size="16" />Preview matches
</button>
<span v-if="status" class="vcenter-status">
<span :class="['status-dot', status.configured ? 'success' : 'warning']"></span>
{{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }}
</span>
</div>
</section>
<section class="wizard-section">
<div class="section-heading">
<span>2</span>
<div>
<h4>Import options</h4>
<p>Attach authentication and host defaults once so every created host is ready for RunPlans.</p>
</div>
</div>
<div class="vcenter-options-grid">
<label>
<span>Vault credential</span>
<select v-model="draft.credentialId">
<option :value="null">No credential</option>
<option v-for="credential in credentials" :key="credential.id" :value="credential.id">{{ credential.name }}</option>
</select>
</label>
<label>
<span>Transport</span>
<select v-model="draft.transport">
<option value="winrm">WinRM</option>
<option value="ssh">SSH</option>
<option value="local">Local</option>
<option value="api">API</option>
</select>
</label>
<label>
<span>Port</span>
<input v-model.number="draft.port" type="number" min="1" max="65535" placeholder="Default" />
</label>
<label>
<span>Visibility</span>
<select v-model="draft.visibility">
<option value="shared">Shared</option>
<option value="personal">Personal</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="draft.visibility === 'group'">
<span>Group</span>
<select v-model="draft.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
<label class="option-tags">
<span>Additional tags</span>
<input v-model="draft.tagsText" placeholder="engineering, intune-ready" />
</label>
</div>
</section>
<section class="wizard-section preview-section">
<div class="section-heading">
<span>3</span>
<div>
<h4>Preview and select</h4>
<p>Guest FQDN/IP data depends on VMware Tools. Missing values are imported with the VM name as the address fallback.</p>
</div>
</div>
<div v-if="error" class="inline-error">{{ error }}</div>
<div class="preview-toolbar">
<button class="ghost-button compact" type="button" :disabled="!previewRows.length" @click="toggleAll">
<CheckSquare :size="15" />{{ allSelected ? 'Clear selection' : 'Select all' }}
</button>
<span>{{ selectedIds.length }} of {{ previewRows.length }} selected</span>
</div>
<div class="vcenter-preview-table">
<table>
<thead>
<tr>
<th>Select</th>
<th>VM / Hostname</th>
<th>FQDN</th>
<th>Address</th>
<th>OS</th>
<th>Power</th>
</tr>
</thead>
<tbody>
<tr v-for="row in previewRows" :key="row.id">
<td>
<input type="checkbox" :checked="selectedIds.includes(row.id)" @change="toggleRow(row.id)" />
</td>
<td>
<strong>{{ row.name }}</strong>
<small>{{ row.id }}</small>
</td>
<td><code>{{ row.fqdn || 'Unavailable' }}</code></td>
<td><code>{{ row.address || row.ipAddress || 'Unavailable' }}</code></td>
<td><span class="status-pill">{{ row.osFamily }}</span></td>
<td>{{ row.powerState || '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="!previewRows.length" class="wizard-empty">
Run a preview to see matching virtual machines before importing hosts.
</div>
</div>
</section>
<div class="form-actions modal-actions">
<button class="ghost-button" type="button" @click="$emit('close')">Cancel</button>
<button class="primary-action" type="submit" :disabled="loading || !selectedIds.length">
<DownloadCloud :size="17" />Import {{ selectedIds.length || '' }} host{{ selectedIds.length === 1 ? '' : 's' }}
</button>
</div>
</form>
</BaseModal>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { CheckSquare, DownloadCloud, Search } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
open: Boolean,
previewRows: { type: Array, default: () => [] },
credentials: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
loading: Boolean,
error: { type: String, default: '' },
status: { type: Object, default: null }
});
const emit = defineEmits(['close', 'preview', 'import']);
const draft = reactive({
filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' },
limit: 100,
credentialId: null,
transport: 'winrm',
port: null,
visibility: 'shared',
groupId: null,
tagsText: ''
});
const selectedIds = ref([]);
const allSelected = computed(() => props.previewRows.length > 0 && selectedIds.value.length === props.previewRows.length);
watch(() => props.previewRows, (rows) => {
selectedIds.value = rows.map((row) => row.id);
});
watch(() => props.open, (open) => {
if (open && props.previewRows.length) selectedIds.value = props.previewRows.map((row) => row.id);
});
function payloadBase() {
return {
filter: { ...draft.filter, value: draft.filter.value.trim() },
limit: Number(draft.limit) || 100
};
}
function emitPreview() {
emit('preview', payloadBase());
}
function toggleRow(id) {
selectedIds.value = selectedIds.value.includes(id)
? selectedIds.value.filter((selectedId) => selectedId !== id)
: [...selectedIds.value, id];
}
function toggleAll() {
selectedIds.value = allSelected.value ? [] : props.previewRows.map((row) => row.id);
}
function submitImport() {
emit('import', {
...payloadBase(),
vmIds: selectedIds.value,
credentialId: draft.credentialId || null,
transport: draft.transport,
port: draft.port ? Number(draft.port) : null,
visibility: draft.visibility,
groupId: draft.visibility === 'group' ? draft.groupId : null,
tags: draft.tagsText.split(',').map((tag) => tag.trim()).filter(Boolean)
});
}
</script>

View File

@@ -29,7 +29,7 @@
<strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong> <strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong>
</button> </button>
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea> <textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea>
<input v-else v-model="row.value" class="settings-input" :placeholder="placeholderFor(key)" /> <input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" />
</div> </div>
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span> <span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
</div> </div>
@@ -48,6 +48,7 @@ defineProps({
labelFor: { type: Function, required: true }, labelFor: { type: Function, required: true },
descriptionFor: { type: Function, required: true }, descriptionFor: { type: Function, required: true },
placeholderFor: { type: Function, required: true }, placeholderFor: { type: Function, required: true },
isSecretSetting: { type: Function, default: () => false },
sourceLabel: { type: Function, required: true }, sourceLabel: { type: Function, required: true },
isBooleanSetting: { type: Function, required: true }, isBooleanSetting: { type: Function, required: true },
normalizedBooleanSetting: { type: Function, required: true } normalizedBooleanSetting: { type: Function, required: true }

View File

@@ -6867,13 +6867,188 @@ input:read-only {
box-shadow: 0 -18px 34px rgba(0, 0, 0, .24); box-shadow: 0 -18px 34px rgba(0, 0, 0, .24);
} }
.vcenter-import-form {
display: grid;
gap: 16px;
max-height: min(74vh, 820px);
overflow: auto;
padding: 2px 2px 0;
}
.wizard-section {
border: 1px solid rgba(139, 224, 255, .14);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .075), rgba(255, 255, 255, .025)),
rgba(7, 13, 28, .52);
padding: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .045);
}
.section-heading {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 12px;
align-items: start;
margin-bottom: 14px;
}
.section-heading > span {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 12px;
color: #06101d;
font-weight: 900;
background: linear-gradient(135deg, #75d8ff, #a579ff 55%, #ffe4f1);
box-shadow: 0 12px 30px rgba(108, 122, 255, .28);
}
.section-heading h4 {
margin: 0;
color: rgba(255, 255, 255, .9);
font-size: .98rem;
}
.section-heading p {
margin: 4px 0 0;
color: rgba(219, 227, 255, .62);
font-size: .82rem;
line-height: 1.45;
}
.vcenter-filter-grid,
.vcenter-options-grid {
display: grid;
grid-template-columns: minmax(150px, .85fr) minmax(150px, .85fr) minmax(220px, 1.35fr) minmax(120px, .55fr);
gap: 12px;
align-items: end;
}
.vcenter-options-grid {
grid-template-columns: repeat(3, minmax(170px, 1fr));
}
.vcenter-filter-grid label,
.vcenter-options-grid label {
min-width: 0;
}
.vcenter-filter-grid .filter-value,
.vcenter-options-grid .option-tags {
grid-column: span 1;
}
.wizard-actions,
.preview-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-top: 14px;
}
.vcenter-status,
.preview-toolbar span {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(219, 227, 255, .68);
font-size: .82rem;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: #f8c66a;
box-shadow: 0 0 14px rgba(248, 198, 106, .62);
}
.status-dot.success {
background: #66f2b5;
box-shadow: 0 0 14px rgba(102, 242, 181, .62);
}
.inline-error {
border: 1px solid rgba(255, 124, 152, .28);
border-radius: 14px;
padding: 10px 12px;
color: #ffb7c7;
background: rgba(255, 65, 112, .08);
margin-bottom: 12px;
}
.vcenter-preview-table {
position: relative;
overflow: auto;
border: 1px solid rgba(139, 224, 255, .12);
border-radius: 16px;
background: rgba(3, 8, 18, .36);
}
.vcenter-preview-table table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
}
.vcenter-preview-table th,
.vcenter-preview-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(139, 224, 255, .08);
text-align: left;
vertical-align: top;
}
.vcenter-preview-table th {
color: rgba(151, 221, 255, .78);
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
background: rgba(7, 13, 28, .72);
}
.vcenter-preview-table td {
color: rgba(238, 242, 255, .84);
font-size: .84rem;
}
.vcenter-preview-table strong,
.vcenter-preview-table small {
display: block;
min-width: 0;
}
.vcenter-preview-table small {
margin-top: 4px;
color: rgba(219, 227, 255, .42);
overflow-wrap: anywhere;
}
.vcenter-preview-table input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #75d8ff;
}
.wizard-empty {
padding: 28px 18px;
color: rgba(219, 227, 255, .56);
text-align: center;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.deployment-section-body.three, .deployment-section-body.three,
.deployment-section-body.two, .deployment-section-body.two,
.return-code-row, .return-code-row,
.assignment-row, .assignment-row,
.relationship-row, .relationship-row,
.intune-deployment-form .installer-analyze-row { .intune-deployment-form .installer-analyze-row,
.vcenter-filter-grid,
.vcenter-options-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

10
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"path": "^0.12.7", "path": "^0.12.7",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"undici": "^8.5.0",
"vite": "^8.1.0", "vite": "^8.1.0",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuetify": "^4.1.2", "vuetify": "^4.1.2",
@@ -3330,6 +3331,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -31,6 +31,7 @@
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"path": "^0.12.7", "path": "^0.12.7",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"undici": "^8.5.0",
"vite": "^8.1.0", "vite": "^8.1.0",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuetify": "^4.1.2", "vuetify": "^4.1.2",

View File

@@ -40,6 +40,13 @@ export const config = {
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin', defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
powershellBin: process.env.POWERSHELL_BIN || 'pwsh', powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true', allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
vcenter: {
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
baseUrl: process.env.VCENTER_BASE_URL || '',
username: process.env.VCENTER_USERNAME || '',
password: process.env.VCENTER_PASSWORD || '',
allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true'
},
entra: { entra: {
enabled: (process.env.ENTRA_ENABLED || 'false').toLowerCase() === 'true', enabled: (process.env.ENTRA_ENABLED || 'false').toLowerCase() === 'true',
tenantId: process.env.ENTRA_TENANT_ID || '', tenantId: process.env.ENTRA_TENANT_ID || '',

View File

@@ -1,5 +1,6 @@
import { hostSchema } from '../forms/schemas.js'; import { hostSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
import { createHost, deleteHost, listHosts, updateHost } from '../models/hostModel.js'; import { createHost, deleteHost, findVisibleHostByIdentity, listHosts, updateHost } from '../models/hostModel.js';
import { buildHostPayloadFromVm, previewVCenterHosts, vcenterStatus } from '../services/vcenterService.js';
export function index(req, res) { export function index(req, res) {
res.json(listHosts(req.user.id)); res.json(listHosts(req.user.id));
@@ -21,3 +22,45 @@ export function destroy(req, res) {
deleteHost(req.params.id, req.user.id); deleteHost(req.params.id, req.user.id);
res.status(204).end(); res.status(204).end();
} }
export function vcenterImportStatus(req, res) {
res.json(vcenterStatus());
}
export async function previewVCenterImport(req, res) {
const parsed = vcenterPreviewSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
try {
res.json(await previewVCenterHosts(parsed.data));
} catch (err) {
res.status(502).json({ error: err.message });
}
}
export async function importVCenterHosts(req, res) {
const parsed = vcenterImportSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
try {
const preview = await previewVCenterHosts(parsed.data);
const selected = new Set(parsed.data.vmIds || []);
const candidates = selected.size
? preview.candidates.filter((candidate) => selected.has(candidate.id))
: preview.candidates;
const imported = [];
const skipped = [];
for (const candidate of candidates) {
const hostPayload = buildHostPayloadFromVm(candidate, parsed.data);
const existing = findVisibleHostByIdentity(hostPayload, req.user.id);
if (existing) {
skipped.push({ vm: candidate, reason: `Existing host matched ${existing.name}` });
continue;
}
imported.push(createHost(hostPayload, req.user.id));
}
res.status(201).json({ imported, skipped, scanned: preview.count });
} catch (err) {
res.status(502).json({ error: err.message });
}
}

View File

@@ -63,6 +63,27 @@ export const hostSchema = z.object({
groupId: z.string().nullable().optional() groupId: z.string().nullable().optional()
}); });
const vcenterFilterSchema = z.object({
field: z.enum(['name', 'hostname', 'fqdn', 'ip']).optional().default('hostname'),
operator: z.enum(['contains', 'equals', 'startsWith', 'endsWith']).optional().default('contains'),
value: z.string().max(200).optional().default('')
});
export const vcenterPreviewSchema = z.object({
filter: vcenterFilterSchema.optional().default({}),
limit: z.coerce.number().int().min(1).max(500).optional().default(100)
});
export const vcenterImportSchema = vcenterPreviewSchema.extend({
vmIds: z.array(z.string().min(1)).optional().default([]),
credentialId: z.string().nullable().optional(),
visibility: visibilitySchema.default('shared'),
groupId: z.string().nullable().optional(),
transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
port: z.coerce.number().int().min(1).max(65535).nullable().optional(),
tags: z.array(z.string().max(80)).optional().default([])
});
export const folderSchema = z.object({ export const folderSchema = z.object({
parentId: z.string().nullable().optional(), parentId: z.string().nullable().optional(),
name: z.string().min(1), name: z.string().min(1),

View File

@@ -42,6 +42,26 @@ export function createHost(payload, userId) {
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId)); return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
} }
export function findVisibleHostByIdentity(payload, userId) {
const values = [payload.name, payload.address, payload.fqdn]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean);
if (!values.length) return null;
const placeholders = values.map(() => '?').join(', ');
const row = db.prepare(`
SELECT *
FROM hosts
WHERE ${visibleClause()}
AND (
lower(name) IN (${placeholders})
OR lower(address) IN (${placeholders})
OR lower(coalesce(fqdn, '')) IN (${placeholders})
)
LIMIT 1
`).get(userId, userId, ...values, ...values, ...values);
return row ? camelHost(row) : null;
}
export function updateHost(hostId, payload, userId) { export function updateHost(hostId, payload, userId) {
const existing = db.prepare(`SELECT * FROM hosts WHERE id = ? AND ${visibleClause()}`).get(hostId, userId, userId); const existing = db.prepare(`SELECT * FROM hosts WHERE id = ? AND ${visibleClause()}`).get(hostId, userId, userId);
if (!existing) return null; if (!existing) return null;

View File

@@ -1,9 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import { create, destroy, index, update } from '../controllers/hostController.js'; import { create, destroy, importVCenterHosts, index, previewVCenterImport, update, vcenterImportStatus } from '../controllers/hostController.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
export const hostRoutes = Router(); export const hostRoutes = Router();
hostRoutes.get('/import/vcenter/status', requireAuth, vcenterImportStatus);
hostRoutes.post('/import/vcenter/preview', requireAuth, previewVCenterImport);
hostRoutes.post('/import/vcenter', requireAuth, importVCenterHosts);
hostRoutes.get('/', requireAuth, index); hostRoutes.get('/', requireAuth, index);
hostRoutes.post('/', requireAuth, create); hostRoutes.post('/', requireAuth, create);
hostRoutes.put('/:id', requireAuth, update); hostRoutes.put('/:id', requireAuth, update);

View File

@@ -0,0 +1,221 @@
import { Buffer } from 'node:buffer';
import { Agent } from 'undici';
import { config } from '../config.js';
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'];
export function getVCenterSettings() {
return {
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
username: getStringSetting('vcenter_username', config.vcenter.username),
password: getStringSetting('vcenter_password', config.vcenter.password),
allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
};
}
export function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
export function vcenterStatus() {
const settings = getVCenterSettings();
return {
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
baseUrl: settings.baseUrl,
username: settings.username,
allowUntrustedTls: settings.allowUntrustedTls
};
}
function assertConfigured(settings) {
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
if (!settings.username || !settings.password) throw new Error('VMware vCenter credentials are missing. Set vcenter_username and vcenter_password in Configuration.');
}
function dispatcherFor(settings) {
return settings.allowUntrustedTls
? new Agent({ connect: { rejectUnauthorized: false } })
: undefined;
}
async function parseVCenterResponse(response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text.replace(/^"|"$/g, '');
}
}
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false } = {}) {
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}`, {
method,
headers,
dispatcher: dispatcherFor(settings)
});
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}`);
}
return payload;
}
function unwrapList(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.value)) return payload.value;
return [];
}
async function createSession(settings) {
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST' });
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.');
return sessionId;
}
async function optionalVCenterFetch(settings, path, sessionId) {
try {
return await vcenterFetch(settings, path, { sessionId, allow404: true });
} catch (err) {
return { unavailable: true, reason: err.message };
}
}
function firstIpFromInterfaces(interfaces = []) {
for (const nic of interfaces) {
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
const firstUsable = addresses.find((entry) => {
const address = entry?.ip_address || entry?.address || entry;
return address && !String(address).startsWith('fe80:');
});
if (firstUsable) return firstUsable.ip_address || firstUsable.address || firstUsable;
}
return '';
}
function allIpsFromInterfaces(interfaces = []) {
return interfaces.flatMap((nic) => {
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
return addresses
.map((entry) => entry?.ip_address || entry?.address || entry)
.filter((address) => address && !String(address).startsWith('fe80:'));
});
}
function inferOsFamily(identity = {}) {
const haystack = [identity.family, identity.name, identity.full_name, identity.guest_OS].filter(Boolean).join(' ').toLowerCase();
if (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
return 'windows';
}
export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
const interfaces = unwrapList(interfacesPayload);
const interfaceIp = firstIpFromInterfaces(interfaces);
const identityIp = identity?.ip_address || identity?.ipAddress || '';
const fqdn = identity?.host_name || identity?.hostName || '';
const name = vm.name || fqdn || vm.vm || vm.id;
const address = fqdn || identityIp || interfaceIp || name;
const ipAddresses = [...new Set([identityIp, interfaceIp, ...allIpsFromInterfaces(interfaces)].filter(Boolean))];
return {
id: vm.vm || vm.id,
name,
fqdn,
address,
ipAddress: ipAddresses[0] || '',
ipAddresses,
powerState: vm.power_state || vm.powerState || '',
osFamily: inferOsFamily(identity || {}),
guestFullName: identity?.full_name || identity?.name || '',
toolsAvailable: Boolean(identity || interfaces.length),
source: 'vcenter'
};
}
export function vmMatchesFilter(candidate, filter = {}) {
const operator = filter.operator || 'contains';
const needle = String(filter.value || '').trim().toLowerCase();
if (!needle) return true;
const field = filter.field || 'hostname';
if (field === 'ip') {
const values = [candidate.ipAddress, ...(candidate.ipAddresses || [])]
.filter(Boolean)
.map((value) => String(value).toLowerCase());
if (operator === 'equals') return values.some((value) => value === needle);
if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
return values.some((value) => value.includes(needle));
}
const haystack = {
name: candidate.name,
hostname: candidate.name || candidate.fqdn,
fqdn: candidate.fqdn || candidate.address,
}[field] || candidate.name;
const value = String(haystack || '').toLowerCase();
if (operator === 'equals') return value === needle;
if (operator === 'startsWith') return value.startsWith(needle);
if (operator === 'endsWith') return value.endsWith(needle);
return value.includes(needle);
}
export function buildHostPayloadFromVm(candidate, options = {}) {
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: candidate.osFamily || 'windows',
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from VMware vCenter VM ${candidate.id}.`,
candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null
};
}
export async function previewVCenterHosts(options = {}) {
const settings = getVCenterSettings();
assertConfigured(settings);
const sessionId = await createSession(settings);
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId });
const vms = unwrapList(vmPayload).slice(0, Math.max(options.limit || 100, 1) * 4);
const candidates = [];
for (const vm of vms) {
const vmId = vm.vm || vm.id;
if (!vmId) continue;
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 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;
}
return {
status: vcenterStatus(),
filter: options.filter || {},
count: candidates.length,
candidates
};
}

View File

@@ -34,6 +34,11 @@ export function settingDefinitions() {
{ key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') }, { key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') },
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') }, { key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') }, { key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') } { key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
{ key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
{ key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') },
{ key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') }
]; ];
} }

View File

@@ -0,0 +1,62 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const {
buildHostPayloadFromVm,
normalizeBaseUrl,
normalizeVCenterVm,
vmMatchesFilter
} = await load('../services/vcenterService.js');
test('normalizeBaseUrl trims trailing slashes', () => {
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
});
test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {
const candidate = normalizeVCenterVm(
{ vm: 'vm-42', name: 'ENG-ENT-APP01', power_state: 'POWERED_ON' },
{ host_name: 'eng-ent-app01.contoso.local', ip_address: '10.42.1.20', full_name: 'Microsoft Windows Server 2022' },
[{ ip: { ip_addresses: [{ ip_address: 'fe80::1' }, { ip_address: '10.42.1.21' }] } }]
);
assert.equal(candidate.id, 'vm-42');
assert.equal(candidate.name, 'ENG-ENT-APP01');
assert.equal(candidate.fqdn, 'eng-ent-app01.contoso.local');
assert.equal(candidate.address, 'eng-ent-app01.contoso.local');
assert.deepEqual(candidate.ipAddresses, ['10.42.1.20', '10.42.1.21']);
assert.equal(candidate.osFamily, 'windows');
});
test('vmMatchesFilter supports hostname contains and IP equals matching', () => {
const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] };
assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'eng-ent' }), true);
assert.equal(vmMatchesFilter(candidate, { field: 'ip', operator: 'equals', value: '10.42.1.20' }), true);
assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false);
});
test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => {
const payload = buildHostPayloadFromVm(
{
id: 'vm-42',
name: 'ENG-ENT-APP01',
fqdn: 'eng-ent-app01.contoso.local',
address: 'eng-ent-app01.contoso.local',
osFamily: 'windows',
powerState: 'POWERED_ON',
guestFullName: 'Microsoft Windows Server 2022',
ipAddresses: ['10.42.1.20']
},
{ credentialId: 'cred_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
);
assert.equal(payload.credentialId, 'cred_1');
assert.equal(payload.transport, 'winrm');
assert.equal(payload.visibility, 'group');
assert.equal(payload.groupId, 'grp_1');
assert.ok(payload.tags.includes('vcenter:vm-42'));
assert.ok(payload.tags.includes('engineering'));
assert.match(payload.notes, /Imported from VMware vCenter VM vm-42/);
});