Initial
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ tools/*.exe
|
|||||||
PLAN*.MD
|
PLAN*.MD
|
||||||
PLAN-INTUNE.md
|
PLAN-INTUNE.md
|
||||||
project.txt
|
project.txt
|
||||||
|
*-lock.json
|
||||||
51
README.md
51
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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.' }
|
||||||
|
|||||||
240
client/src/components/hosts/VCenterImportModal.vue
Normal file
240
client/src/components/hosts/VCenterImportModal.vue
Normal 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>
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
221
server/services/vcenterService.js
Normal file
221
server/services/vcenterService.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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') }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
62
server/test/vcenter.test.mjs
Normal file
62
server/test/vcenter.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user