This commit is contained in:
2026-06-25 16:51:56 -05:00
parent 94f4d8959d
commit 402bf6782a
25 changed files with 1323 additions and 103 deletions

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.
- 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.
- Host Library with searchable/sortable/paginated table, modal add/edit flow, VMware vCenter VM import wizard, and manual/dynamic Host Groups for RunPlan and script-test targeting.
- Host Library with searchable/sortable/paginated table, modal add/edit flow, VMware vCenter VM import wizard, standalone ESXi connection records, and manual/dynamic Host Groups for RunPlan and script-test targeting.
- 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.
- Aggregate job log viewer with search/filter/pagination.
@@ -506,12 +506,12 @@ Payload:
| --- | --- | --- | --- |
| `GET` | `/api/hosts` | User | List host library. |
| `POST` | `/api/hosts` | User | Create host. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved vCenter systems. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved vCenter systems. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted vCenter system record. |
| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible vCenter system; omit password to keep the existing secret. |
| `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible vCenter system. |
| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Authenticate and list VM summaries to verify a vCenter system. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted VMware connection record. Use `targetType: "vcenter"` for inventory or `targetType: "host"` for standalone ESXi Web Services testing. |
| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible VMware connection; omit password to keep the existing secret. |
| `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible VMware connection. |
| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Verify a VMware connection. vCenter records authenticate and list VM summaries; standalone host records call the vSphere Web Services SOAP endpoint. |
| `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. |
| `GET` | `/api/hosts/groups` | User | List visible manual and dynamic Host Groups with member counts. |
@@ -550,7 +550,11 @@ target hosts visible to the operator who creates or edits them. `sourceType`,
`sourceConnectionId`, and `sourceRef` are hidden provenance fields. Manual
hosts default to `sourceType: "manual"`. vCenter imports set
`sourceType: "vmware"`, `sourceConnectionId` to the selected vCenter system,
and `sourceRef` to the vCenter VM id.
and `sourceRef` to the vCenter VM id. `osFamily` is normalized to `windows`,
`linux`, or `other`; older values such as `network` or `api` are treated as
`other`. Manual host entry should set this explicitly when POSHManager cannot
infer it from inventory. vCenter imports infer Windows/Linux from VMware Tools
guest identity when available; unknown guests are imported as `other`.
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
without duplicating RunPlans. Manual groups store an explicit list of visible
@@ -595,12 +599,17 @@ Dynamic group rule fields are `name`, `fqdn`, `address`, `tags`, `transport`,
"all"` or `matchMode: "any"`. Operators can use `contains`, `equals`,
`startsWith`, and `endsWith` operators. Rule matching is evaluated only against
hosts the group owner/creator can see through personal/shared/group visibility.
The Host Groups Target Collections table includes search, sort, pagination, and
a read-only summary action. The summary view displays the rule/mode and assigned
machines, then exports CSV or Excel-readable `.xls` files with hostname,
IP address, and vCenter/VMware source system columns.
Create/update a saved vCenter system:
Create/update a saved vCenter inventory system:
```json
{
"name": "Production vCenter",
"targetType": "vcenter",
"baseUrl": "https://vcenter.contoso.local",
"username": "administrator@vsphere.local",
"password": "stored-encrypted-on-save",
@@ -613,10 +622,33 @@ Create/update a saved vCenter system:
}
```
Saved vCenter passwords are encrypted with the same credential-store key used
by the Credential Vault. Existing `vcenter_*` settings and `VCENTER_*`
environment variables remain supported as the `Configured default` import
connection for single-vCenter deployments or Docker-provided configuration.
Create/update a saved standalone ESXi host connection:
```json
{
"name": "Standalone ESXi 01",
"targetType": "host",
"baseUrl": "https://esxi01.contoso.local",
"username": "root",
"password": "stored-encrypted-on-save",
"apiMode": "web-services",
"requestTimeoutMs": 20000,
"allowUntrustedTls": true,
"enabled": true,
"visibility": "shared",
"groupId": null
}
```
Saved VMware passwords are encrypted with the same credential-store key used
by the Credential Vault. `targetType: "vcenter"` records use the vSphere
Automation API or legacy REST API for inventory and VM power-state checks.
`targetType: "host"` records are standalone host connections that use the
vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi
connectivity but cannot perform vCenter VM inventory imports. Existing
`vcenter_*` settings and `VCENTER_*` environment variables remain supported as
the `Configured default` import connection for single-vCenter deployments or
Docker-provided configuration.
VMware vCenter import can use a saved vCenter system selected by `connectionId` or the legacy configured default from Config / Integrations (`vcenter_*` settings or `VCENTER_*` environment variables). `apiMode` / `vcenter_api_mode` can be `auto`, `api`, or `rest`. `api` uses the current vSphere Automation API flow (`POST /api/session`, `GET /api/vcenter/vm`). `rest` uses the legacy/community vCenter REST flow (`POST /rest/com/vmware/cis/session`, `GET /rest/vcenter/vm`). `auto` tries the current `/api` profile first and falls back to `/rest` when the endpoint is not supported.
@@ -676,6 +708,9 @@ are logged and marked `skipped` for that host, so powered-off machines are not
treated like script failures. If the vCenter connection is missing or the
management API is temporarily unavailable, the runner logs the reason and
continues execution rather than blocking the whole job on a control-plane issue.
Standalone ESXi host connection records do not provide vCenter VM inventory
state, so VMware-sourced imported guests continue to use their saved vCenter
source connection for pre-run power checks.
### Script Library
@@ -688,6 +723,7 @@ continues execution rather than blocking the whole job on a control-plane issue.
| `GET` | `/api/scripts` | User | List visible scripts. |
| `POST` | `/api/scripts` | User | Create script and initial version. |
| `GET` | `/api/scripts/:id` | User | Read script. |
| `POST` | `/api/scripts/:id/compatibility` | User | Analyze a script against selected host or Host Group targets before execution. |
| `PUT` | `/api/scripts/:id` | User | Update script and create a version record. |
| `DELETE` | `/api/scripts/:id` | User | Delete script. |
| `GET` | `/api/scripts/:id/versions` | User | List script versions. |
@@ -1112,6 +1148,7 @@ with per-host log rows.
| `GET` | `/api/runplans` | User | List visible RunPlans. |
| `POST` | `/api/runplans` | User | Create RunPlan. |
| `GET` | `/api/runplans/:id` | User | Read RunPlan. |
| `GET` | `/api/runplans/:id/compatibility` | User | Analyze the RunPlan script against its current direct and Host Group targets. |
| `PUT` | `/api/runplans/:id` | User | Update RunPlan. |
| `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. |
| `POST` | `/api/runplans/:id/execute` | User | Queue/execute a RunPlan. |
@@ -1148,6 +1185,26 @@ curl -X POST http://localhost:3000/api/runplans/rp_123/execute \
-d '{}'
```
Linux compatibility preflight:
```bash
curl -X POST http://localhost:3000/api/scripts/scr_123/compatibility \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"hostIds":["hst_linux_01"],"hostGroupIds":["hg_mixed_targets"]}'
curl http://localhost:3000/api/runplans/rp_123/compatibility \
-H "Authorization: Bearer $TOKEN"
```
The compatibility response includes the selected target OS mix and findings
when Linux hosts are involved. The analyzer flags common cross-platform issues
such as Windows registry providers, `C:\` or UNC paths, Windows-only
executables such as `msiexec.exe`/`reg.exe`, WMI/`Win32_*` patterns,
Windows-specific environment variables, and alias-heavy script style. RunPlan
execution and Script Test / Run also write the same warning summary into
per-host job logs for Linux targets.
### Settings And Logs
| Method | Route | Auth | Description |
@@ -1176,6 +1233,7 @@ The runner writes a temporary PowerShell wrapper and launches `pwsh` with enviro
- `ssh` hosts use `Invoke-Command -HostName`.
- Host credential resolution happens inside backend execution flows.
- POSHManager runtime variables are injected as PowerShell variables and `POSHM_*` environment variables.
- `POSHM_HOST_OS_FAMILY` is injected with `windows`, `linux`, or `other` so scripts can branch on the target OS type.
- Custom variables are decrypted server-side, token-rendered for `{{VariableName}}`, and injected into the script block as PowerShell variables before execution.
- Asset references are token-rendered for `{{asset:<assetId>}}` and exposed as `POSHM_ASSET_MANIFEST` JSON. Local paths point to the API container asset store; remote scripts must copy or otherwise stage those files before using them on target systems.
- PSADT variables are not emulated by POSHManager; they are available when the script runs inside a valid PSAppDeployToolkit session.
@@ -1183,6 +1241,11 @@ The runner writes a temporary PowerShell wrapper and launches `pwsh` with enviro
Remote PowerShell prerequisites, firewall rules, remoting policy, SSH remoting, and target credentials must be configured outside POSHManager.
PowerShell on Linux requires a supported distro package/runtime and scripts must
avoid assuming Windows-only subsystems. Prefer full cmdlet names over aliases,
branch OS-specific logic with `$IsWindows`/`$IsLinux`, and test modules/cmdlets
on the target platform before broad execution.
## Security Notes
- SQLite access uses prepared statements through `node:sqlite`.

View File

@@ -331,6 +331,7 @@
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-address="{ row }"><code>{{ row.address }}</code></template>
<template #cell-osFamily="{ row }"><span :class="['status-pill', row.osFamily || 'other']">{{ osFamilyLabel(row.osFamily) }}</span></template>
<template #cell-transport="{ row }"><span :class="['status-pill', row.transport]">{{ row.transport }}</span></template>
<template #cell-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
@@ -462,7 +463,7 @@
<form @submit.prevent="saveHost" class="form-grid">
<label><span>Display name</span><input v-model="hostForm.name" required placeholder="SQL-PROD-01" /></label>
<label><span>Address / FQDN</span><input v-model="hostForm.address" required placeholder="sql-prod-01.contoso.local" /></label>
<label><span>OS family</span><select v-model="hostForm.osFamily"><option value="windows">Windows</option><option value="linux">Linux</option><option value="network">Network</option><option value="api">API</option></select></label>
<label><span>Operating system type</span><select v-model="hostForm.osFamily"><option value="windows">Windows</option><option value="linux">Linux</option><option value="other">Other / unknown</option></select></label>
<label><span>Transport</span><select v-model="hostForm.transport"><option value="winrm">WinRM</option><option value="ssh">SSH</option><option value="local">Local</option><option value="api">API</option></select></label>
<label class="full"><span>Credential</span><select v-model="hostForm.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 class="full"><span>Tags</span><input v-model="hostForm.tagsText" placeholder="production, database, east" /></label>
@@ -1326,6 +1327,7 @@ const availableThemes = [
const hostColumns = [
{ key: 'name', label: 'Name' },
{ key: 'address', label: 'Address' },
{ key: 'osFamily', label: 'OS' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'credentialName', label: 'Credential' },
@@ -1469,6 +1471,7 @@ const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host)
host.name,
host.address,
host.fqdn,
host.osFamily,
host.transport,
host.sourceType,
host.sourceConnectionName,
@@ -1945,6 +1948,10 @@ async function executeScriptTestRun(payload) {
scriptTestPollFailures = 0;
stopScriptTestPolling();
try {
if (!(await confirmLinuxCompatibility(`/api/scripts/${scriptTestTarget.value.id}/compatibility`, payload))) {
scriptTestRunning.value = false;
return;
}
const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload);
scriptTestJob.value = await api.get(`/api/jobs/${job.id}`).catch(() => job);
startScriptTestPolling(job.id);
@@ -2471,6 +2478,12 @@ async function saveHost() {
notify('Host saved');
}
function osFamilyLabel(value) {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
async function saveHostGroup(payload) {
const body = { ...payload };
const groupId = body.id;
@@ -2669,6 +2682,7 @@ function openRunPlanModal(runplan = null) {
}
async function executeRunPlan(id) {
if (!(await confirmLinuxCompatibility(`/api/runplans/${id}/compatibility`))) return;
const job = await api.post(`/api/runplans/${id}/execute`, {});
await refreshAll();
await openJob(job.id);
@@ -2676,6 +2690,20 @@ async function executeRunPlan(id) {
notify('RunPlan queued');
}
async function confirmLinuxCompatibility(path, payload = null) {
const result = payload ? await api.post(path, payload) : await api.get(path);
const warnings = (result.findings || []).filter((finding) => finding.level === 'warning');
if (!result.hasLinuxTargets || !warnings.length) return true;
const lines = warnings.slice(0, 5).map((finding) => `- ${finding.title}${finding.line ? ` line ${finding.line}` : ''}: ${finding.message}`);
return window.confirm([
'This script is targeting one or more Linux hosts and POSHManager found possible compatibility issues.',
'',
...lines,
'',
'Continue anyway? The same warnings will be written to the job logs.'
].join('\n'));
}
async function openJob(id) {
selectedJob.value = await api.get(`/api/jobs/${id}`);
view.value = 'logs';

View File

@@ -8,23 +8,31 @@
</div>
<div class="section-actions">
<button class="ghost-button compact" type="button" @click="$emit('sync-all')">
<RefreshCcw :size="15" />Sync dynamic
<i class="mdi mdi-sync" aria-hidden="true"></i>Sync dynamic
</button>
<button class="primary-action compact" type="button" @click="openModal()">
<Plus :size="15" />Add host group
<i class="mdi mdi-plus" aria-hidden="true"></i>Add host group
</button>
</div>
</div>
<ResourceTable
:columns="columns"
:rows="hostGroups"
:total="hostGroups.length"
:rows="pagedHostGroups"
:total="filteredHostGroups.length"
:search="groupSearch"
:page="groupPage"
:page-size="groupPageSize"
:sort="groupSort"
:direction="groupDirection"
:page-count="groupPageCount"
search-placeholder="Search collections, rules, machines..."
item-label="host groups"
empty-text="No host groups yet."
:page-count="1"
:page="1"
:page-size="hostGroups.length || 8"
@update:search="groupSearch = $event"
@update:page="groupPage = $event"
@update:pageSize="groupPageSize = $event"
@sort="toggleGroupSort"
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-mode="{ row }"><span :class="['status-pill', row.mode]">{{ row.mode }}</span></template>
@@ -33,13 +41,93 @@
<template #cell-lastSyncedAt="{ row }">{{ row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleString() : '-' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button v-if="row.mode === 'dynamic'" class="ghost-button compact" type="button" @click="$emit('sync', row.id)">Sync</button>
<button class="ghost-button compact" type="button" @click="openModal(row)">Edit</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', row.id)">Delete</button>
<button class="icon-button material-icon-button" type="button" title="View target collection" aria-label="View target collection" @click="openSummary(row)">
<i class="mdi mdi-magnify" aria-hidden="true"></i>
</button>
<button v-if="row.mode === 'dynamic'" class="icon-button material-icon-button" type="button" title="Sync dynamic group" aria-label="Sync dynamic group" @click="$emit('sync', row.id)">
<i class="mdi mdi-sync" aria-hidden="true"></i>
</button>
<button class="icon-button material-icon-button" type="button" title="Edit host group" aria-label="Edit host group" @click="openModal(row)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="icon-button material-icon-button danger-text" type="button" title="Delete host group" aria-label="Delete host group" @click="$emit('delete', row.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
<BaseModal
:open="summaryOpen"
:title="summaryGroup ? summaryGroup.name : 'Target collection'"
eyebrow="TARGET COLLECTION"
description="Read-only rule and membership summary for this Host Group."
wide
@close="summaryOpen = false"
>
<section v-if="summaryGroup" class="host-group-summary-modal">
<div class="summary-metrics">
<span><strong>{{ summaryHosts.length }}</strong><small>assigned machines</small></span>
<span><strong>{{ osBreakdown.windows }}</strong><small>windows</small></span>
<span><strong>{{ osBreakdown.linux }}</strong><small>linux</small></span>
<span><strong>{{ osBreakdown.other }}</strong><small>other</small></span>
</div>
<div class="summary-rule-panel">
<div>
<span class="eyebrow">RULE SUMMARY</span>
<h4>{{ summaryGroup.mode === 'dynamic' ? 'Dynamic rule set' : 'Manual membership' }}</h4>
<p>{{ ruleSummary(summaryGroup) }}</p>
</div>
<span :class="['status-pill', summaryGroup.mode]">{{ summaryGroup.mode }}</span>
</div>
<div class="summary-actions">
<button class="ghost-button compact" type="button" :disabled="!summaryHosts.length" @click="exportSummary('csv')">
<i class="mdi mdi-file-delimited-outline" aria-hidden="true"></i>CSV
</button>
<button class="ghost-button compact" type="button" :disabled="!summaryHosts.length" @click="exportSummary('xls')">
<i class="mdi mdi-file-excel-outline" aria-hidden="true"></i>XLS
</button>
</div>
<div class="member-table-wrap readonly-members">
<table class="member-table">
<thead>
<tr>
<th>Hostname</th>
<th>IP address</th>
<th>OS</th>
<th>Transport</th>
<th>vCenter System</th>
</tr>
</thead>
<tbody>
<tr v-for="host in summaryHosts" :key="host.id">
<td>
<strong>{{ host.fqdn || host.name }}</strong>
<small>{{ host.name }}</small>
</td>
<td><code>{{ host.address || '-' }}</code></td>
<td><span :class="['status-pill', host.osFamily || 'other']">{{ osFamilyLabel(host.osFamily) }}</span></td>
<td><span class="status-pill neutral">{{ host.transport || '-' }}</span></td>
<td>{{ host.sourceConnectionName || (host.sourceType === 'vmware' ? 'Unknown VMware source' : '-') }}</td>
</tr>
<tr v-if="!summaryHosts.length">
<td colspan="5" class="member-empty">No machines are currently assigned to this collection.</td>
</tr>
</tbody>
</table>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="summaryOpen = false">
<i class="mdi mdi-close" aria-hidden="true"></i>Close
</button>
</div>
</section>
</BaseModal>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update host group' : 'Create host group'"
@@ -70,12 +158,21 @@
<div class="member-toolbar">
<label class="member-search">
<Search :size="15" />
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<input v-model="memberSearch" placeholder="Search host, FQDN, address, tags..." />
<button v-if="memberSearch" type="button" aria-label="Clear host search" @click="memberSearch = ''">
<X :size="13" />
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</label>
<label>
<span>OS</span>
<select v-model="memberOsFamily">
<option value="">All OS types</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="other">Other</option>
</select>
</label>
<label>
<span>Source</span>
<select v-model="memberSource">
@@ -91,7 +188,7 @@
</select>
</label>
<button :class="['ghost-button compact', { active: selectedOnly }]" type="button" @click="selectedOnly = !selectedOnly">
<ListChecks :size="15" />Selected
<i class="mdi mdi-format-list-checks" aria-hidden="true"></i>Selected
</button>
</div>
@@ -105,7 +202,7 @@
Clear page
</button>
<button class="ghost-button compact danger-text" type="button" :disabled="!form.hostIds.length" @click="form.hostIds = []">
Clear all
<i class="mdi mdi-close-circle-outline" aria-hidden="true"></i>Clear all
</button>
</div>
</div>
@@ -123,10 +220,11 @@
@change="$event.target.checked ? selectPage() : clearPage()"
/>
</th>
<th><button type="button" @click="toggleMemberSort('name')">Host <component :is="memberSortIcon('name')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('address')">Address <component :is="memberSortIcon('address')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('transport')">Transport <component :is="memberSortIcon('transport')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('sourceType')">Source <component :is="memberSortIcon('sourceType')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('name')">Host <i :class="memberSortIcon('name')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('address')">Address <i :class="memberSortIcon('address')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('osFamily')">OS <i :class="memberSortIcon('osFamily')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('transport')">Transport <i :class="memberSortIcon('transport')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('sourceType')">Source <i :class="memberSortIcon('sourceType')" aria-hidden="true"></i></button></th>
<th>Tags</th>
</tr>
</thead>
@@ -140,12 +238,13 @@
<small>{{ host.fqdn || 'No FQDN recorded' }}</small>
</td>
<td><code>{{ host.address || '-' }}</code></td>
<td><span :class="['status-pill', host.osFamily || 'other']">{{ osFamilyLabel(host.osFamily) }}</span></td>
<td><span class="status-pill neutral">{{ host.transport || '-' }}</span></td>
<td><span :class="['status-pill', host.sourceType === 'vmware' ? 'dynamic' : 'manual']">{{ sourceLabel(host.sourceType) }}</span></td>
<td><span class="member-tags">{{ tagSummary(host) }}</span></td>
</tr>
<tr v-if="!pagedHosts.length">
<td colspan="6" class="member-empty">No hosts match the current filters.</td>
<td colspan="7" class="member-empty">No hosts match the current filters.</td>
</tr>
</tbody>
</table>
@@ -163,10 +262,10 @@
<span>Page {{ Math.min(memberPage, memberPageCount) }} of {{ memberPageCount }}</span>
<div>
<button class="ghost-button compact" type="button" :disabled="memberPage <= 1" @click="memberPage -= 1">
<ChevronLeft :size="15" />Previous
<i class="mdi mdi-chevron-left" aria-hidden="true"></i>Previous
</button>
<button class="ghost-button compact" type="button" :disabled="memberPage >= memberPageCount" @click="memberPage += 1">
Next<ChevronRight :size="15" />
Next<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -193,15 +292,15 @@
</select>
<input v-model="rule.value" required placeholder="ENG" />
<button class="ghost-button compact danger-text" type="button" @click="removeRule(index)">
<Trash2 :size="14" />
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</button>
</div>
<button class="ghost-button compact" type="button" @click="addRule"><Plus :size="14" />Add rule</button>
<button class="ghost-button compact" type="button" @click="addRule"><i class="mdi mdi-plus" aria-hidden="true"></i>Add rule</button>
</section>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />Save host group</button>
<button class="primary-action" type="submit"><i class="mdi mdi-content-save-outline" aria-hidden="true"></i>Save host group</button>
</div>
</form>
</BaseModal>
@@ -210,7 +309,6 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, ListChecks, Plus, RefreshCcw, Save, Search, Trash2, X } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
import ResourceTable from '../ui/ResourceTable.vue';
@@ -222,7 +320,15 @@ const props = defineProps({
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
const modalOpen = ref(false);
const summaryOpen = ref(false);
const summaryGroup = ref(null);
const groupSearch = ref('');
const groupSort = ref('name');
const groupDirection = ref('asc');
const groupPage = ref(1);
const groupPageSize = ref(8);
const memberSearch = ref('');
const memberOsFamily = ref('');
const memberSource = ref('');
const memberTransport = ref('');
const selectedOnly = ref(false);
@@ -242,11 +348,37 @@ const columns = [
const sourceOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.sourceType || 'manual')));
const transportOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.transport).filter(Boolean)));
const filteredHostGroups = computed(() => {
const term = groupSearch.value.trim().toLowerCase();
const rows = props.hostGroups.filter((group) => {
if (!term) return true;
return [
group.name,
group.description,
group.mode,
group.visibility,
group.groupName,
ruleSummary(group)
].filter(Boolean).some((value) => String(value).toLowerCase().includes(term));
});
return rows.sort((a, b) => {
const left = sortableGroupValue(a, groupSort.value);
const right = sortableGroupValue(b, groupSort.value);
const result = left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
return groupDirection.value === 'asc' ? result : -result;
});
});
const groupPageCount = computed(() => Math.max(1, Math.ceil(filteredHostGroups.value.length / groupPageSize.value)));
const pagedHostGroups = computed(() => {
const start = (groupPage.value - 1) * groupPageSize.value;
return filteredHostGroups.value.slice(start, start + groupPageSize.value);
});
const filteredHosts = computed(() => {
const term = memberSearch.value.trim().toLowerCase();
const selected = new Set(form.hostIds);
return props.hosts.filter((host) => {
if (selectedOnly.value && !selected.has(host.id)) return false;
if (memberOsFamily.value && (host.osFamily || 'other') !== memberOsFamily.value) return false;
if (memberSource.value && (host.sourceType || 'manual') !== memberSource.value) return false;
if (memberTransport.value && host.transport !== memberTransport.value) return false;
if (!term) return true;
@@ -254,6 +386,7 @@ const filteredHosts = computed(() => {
host.name,
host.fqdn,
host.address,
host.osFamily,
host.transport,
host.sourceType,
...(host.tags || [])
@@ -271,11 +404,30 @@ const pagedHosts = computed(() => {
return filteredHosts.value.slice(start, start + memberPageSize.value);
});
const pageAllSelected = computed(() => Boolean(pagedHosts.value.length) && pagedHosts.value.every((host) => form.hostIds.includes(host.id)));
const summaryHosts = computed(() => {
const ids = new Set(summaryGroup.value?.hostIds || []);
return props.hosts
.filter((host) => ids.has(host.id))
.sort((a, b) => String(a.fqdn || a.name).localeCompare(String(b.fqdn || b.name), undefined, { numeric: true, sensitivity: 'base' }));
});
const osBreakdown = computed(() => summaryHosts.value.reduce((counts, host) => {
const key = ['windows', 'linux'].includes(host.osFamily) ? host.osFamily : 'other';
counts[key] += 1;
return counts;
}, { windows: 0, linux: 0, other: 0 }));
watch([memberSearch, memberSource, memberTransport, selectedOnly, memberPageSize], () => {
watch([memberSearch, memberOsFamily, memberSource, memberTransport, selectedOnly, memberPageSize], () => {
memberPage.value = 1;
});
watch([groupSearch, groupPageSize], () => {
groupPage.value = 1;
});
watch(groupPageCount, (pageCount) => {
if (groupPage.value > pageCount) groupPage.value = pageCount;
});
watch(memberPageCount, (pageCount) => {
if (memberPage.value > pageCount) memberPage.value = pageCount;
});
@@ -309,6 +461,11 @@ function openModal(group = null) {
modalOpen.value = true;
}
function openSummary(group) {
summaryGroup.value = group;
summaryOpen.value = true;
}
function addRule() {
form.rules.push({ field: 'name', operator: 'contains', value: '' });
}
@@ -328,10 +485,24 @@ function uniqueOptions(values) {
}
function sortableHostValue(host, key) {
if (key === 'osFamily') return host.osFamily || 'other';
if (key === 'sourceType') return host.sourceType || 'manual';
return String(host[key] || '');
}
function sortableGroupValue(group, key) {
if (key === 'memberCount') return String(group.memberCount || group.hostIds?.length || 0).padStart(8, '0');
if (key === 'lastSyncedAt') return group.lastSyncedAt || '';
if (key === 'rules') return ruleSummary(group);
return String(group[key] || '');
}
function osFamilyLabel(value = 'other') {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
function toggleMemberSort(key) {
if (memberSort.value === key) {
memberDirection.value = memberDirection.value === 'asc' ? 'desc' : 'asc';
@@ -341,9 +512,18 @@ function toggleMemberSort(key) {
memberDirection.value = 'asc';
}
function toggleGroupSort(key) {
if (groupSort.value === key) {
groupDirection.value = groupDirection.value === 'asc' ? 'desc' : 'asc';
return;
}
groupSort.value = key;
groupDirection.value = 'asc';
}
function memberSortIcon(key) {
if (memberSort.value !== key) return ChevronsUpDown;
return memberDirection.value === 'asc' ? ArrowUpAZ : ArrowDownAZ;
if (memberSort.value !== key) return 'mdi mdi-swap-vertical';
return memberDirection.value === 'asc' ? 'mdi mdi-sort-alphabetical-ascending' : 'mdi mdi-sort-alphabetical-descending';
}
function selectPage() {
@@ -365,6 +545,62 @@ function tagSummary(host) {
return host.tags?.length ? host.tags.slice(0, 3).join(', ') : '-';
}
function exportSummary(format) {
if (!summaryGroup.value || !summaryHosts.value.length) return;
const rows = summaryHosts.value.map((host) => ({
Hostname: host.fqdn || host.name || '',
'IP address': host.address || '',
'vCenter System': host.sourceConnectionName || (host.sourceType === 'vmware' ? 'Unknown VMware source' : '')
}));
if (format === 'xls') {
downloadFile(`${safeFileName(summaryGroup.value.name)}-hosts.xls`, excelHtml(rows), 'application/vnd.ms-excel;charset=utf-8');
return;
}
downloadFile(`${safeFileName(summaryGroup.value.name)}-hosts.csv`, csvText(rows), 'text/csv;charset=utf-8');
}
function csvText(rows) {
const headers = ['Hostname', 'IP address', 'vCenter System'];
return [
headers.join(','),
...rows.map((row) => headers.map((header) => csvCell(row[header])).join(','))
].join('\n');
}
function excelHtml(rows) {
const headers = ['Hostname', 'IP address', 'vCenter System'];
return `<!doctype html><html><head><meta charset="utf-8"></head><body><table><thead><tr>${headers.map((header) => `<th>${htmlEscape(header)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((header) => `<td>${htmlEscape(row[header])}</td>`).join('')}</tr>`).join('')}</tbody></table></body></html>`;
}
function downloadFile(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function csvCell(value) {
const text = String(value ?? '');
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function htmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function safeFileName(value) {
return String(value || 'host-group').trim().replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '') || 'host-group';
}
function save() {
emit('save', {
...form,

View File

@@ -21,7 +21,7 @@
<span>vCenter system</span>
<select v-model="draft.connectionId">
<option :value="null">Configured default</option>
<option v-for="connection in connections" :key="connection.id" :value="connection.id">
<option v-for="connection in importConnections" :key="connection.id" :value="connection.id">
{{ connection.name }} - {{ connection.baseUrl }}
</option>
</select>
@@ -55,14 +55,14 @@
</div>
<div class="wizard-actions">
<button class="ghost-button" type="button" :disabled="loading" @click="emitPreview">
<Search :size="16" />Preview matches
<i class="mdi mdi-magnify" aria-hidden="true"></i>Preview matches
</button>
<span v-if="status" class="vcenter-status">
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
</span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />View API trace
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>View API trace
</button>
</div>
</section>
@@ -133,13 +133,13 @@
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
<span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />Trace
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>Trace
</button>
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
</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' }}
<i class="mdi mdi-checkbox-marked-outline" aria-hidden="true"></i>{{ allSelected ? 'Clear selection' : 'Select all' }}
</button>
<span>{{ selectedIds.length }} of {{ previewRows.length }} selected</span>
</div>
@@ -180,7 +180,7 @@
<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' }}
<i class="mdi mdi-cloud-download-outline" aria-hidden="true"></i>Import {{ selectedIds.length || '' }} host{{ selectedIds.length === 1 ? '' : 's' }}
</button>
</div>
</form>
@@ -189,7 +189,6 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { CheckSquare, DownloadCloud, FileSearch, Search } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
@@ -220,10 +219,11 @@ const draft = reactive({
});
const selectedIds = ref([]);
const importConnections = computed(() => props.connections.filter((connection) => connection.targetType !== 'host'));
const allSelected = computed(() => props.previewRows.length > 0 && selectedIds.value.length === props.previewRows.length);
const activeStatus = computed(() => {
if (draft.connectionId) {
const connection = props.connections.find((item) => item.id === draft.connectionId);
const connection = importConnections.value.find((item) => item.id === draft.connectionId);
return {
name: connection?.name || 'Selected vCenter',
configured: Boolean(connection?.baseUrl && connection?.username),
@@ -233,6 +233,12 @@ const activeStatus = computed(() => {
return props.status?.defaultConnection || props.status || {};
});
watch(importConnections, (connections) => {
if (draft.connectionId && !connections.some((connection) => connection.id === draft.connectionId)) {
draft.connectionId = null;
}
});
watch(() => props.previewRows, (rows) => {
selectedIds.value = rows.map((row) => row.id);
});

View File

@@ -21,7 +21,7 @@
<select v-if="targetType === 'host'" v-model="hostId" required>
<option value="" disabled>Choose host</option>
<option v-for="host in hosts" :key="host.id" :value="host.id">
{{ host.name }} - {{ host.address }} ({{ host.transport }})
{{ host.name }} - {{ host.address }} ({{ host.transport }} / {{ osFamilyLabel(host.osFamily) }})
</option>
</select>
<select v-else v-model="hostGroupId" required>
@@ -47,6 +47,11 @@
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
</div>
<section v-if="linuxTargetCount" class="compatibility-callout">
<strong>Linux compatibility preflight</strong>
<p>{{ linuxTargetCount }} selected Linux target{{ linuxTargetCount === 1 ? '' : 's' }}. POSHManager will check for common Windows-only PowerShell patterns before execution and write findings to the job log.</p>
</section>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
<button class="primary-action" type="submit" :disabled="running || !targetSelected || !credentialId">
@@ -98,8 +103,14 @@ const selectedHost = computed(() => props.hosts.find((host) => host.id === hostI
const selectedHostGroup = computed(() => props.hostGroups.find((group) => group.id === hostGroupId.value));
const selectedCredential = computed(() => props.credentials.find((credential) => credential.id === credentialId.value));
const targetSelected = computed(() => targetType.value === 'group' ? Boolean(hostGroupId.value) : Boolean(hostId.value));
const selectedTargets = computed(() => {
if (targetType.value === 'host') return selectedHost.value ? [selectedHost.value] : [];
const ids = new Set(selectedHostGroup.value?.hostIds || []);
return props.hosts.filter((host) => ids.has(host.id));
});
const linuxTargetCount = computed(() => selectedTargets.value.filter((host) => host.osFamily === 'linux').length);
const selectedHostSummary = computed(() => selectedHost.value
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${osFamilyLabel(selectedHost.value.osFamily)} / ${selectedHost.value.address}`
: (selectedHostGroup.value ? `${selectedHostGroup.value.name} / ${selectedHostGroup.value.mode} / ${selectedHostGroup.value.memberCount || 0} host(s)` : 'No target selected'));
const selectedCredentialSummary = computed(() => selectedCredential.value
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
@@ -144,4 +155,10 @@ function execute() {
credentialId: credentialId.value
});
}
function osFamilyLabel(value = 'other') {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
</script>

View File

@@ -3,62 +3,89 @@
<div class="section-title">
<div>
<span class="eyebrow">CONFIG / VMWARE</span>
<h3>VMware vCenter</h3>
<p>Register one or more vCenter systems used for host imports and execution-time power checks.</p>
<h3>VMware connections</h3>
<p>Register vCenter inventory systems or standalone ESXi hosts for VMware-aware host management.</p>
</div>
<button class="primary-action compact" type="button" @click="openModal()">
<CloudCog :size="15" />Add vCenter
<i class="mdi mdi-server-network" aria-hidden="true"></i>Add VMware connection
</button>
</div>
<div class="intune-config-grid">
<section class="publisher-card">
<strong>Inventory sources</strong>
<p>Imported VMware hosts keep their source connection and VM id so runs can skip guests that are powered off.</p>
<p>vCenter entries import VM inventory and power-state checks. Standalone host entries validate direct ESXi connections through the vSphere Web Services API.</p>
<div class="config-stat-row">
<span><b>{{ connections.length }}</b><small>systems</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
<span><b>{{ vcenterCount }}</b><small>vCenter</small></span>
<span><b>{{ standaloneCount }}</b><small>hosts</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span>
</div>
</section>
<section class="publisher-card graph-config-list">
<strong>vCenter systems</strong>
<strong>VMware systems</strong>
<div v-for="connection in connections" :key="connection.id" class="check-row">
<b>{{ connection.name }}</b>
<small>{{ connection.baseUrl }} - {{ connection.apiMode }} - {{ connection.lastTestStatus || 'untested' }}</small>
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.apiMode }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span>
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
<button class="ghost-button compact" type="button" @click="openModal(connection)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">
<i class="mdi mdi-lan-check" aria-hidden="true"></i>Test
</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>Delete
</button>
</span>
</div>
<p v-if="!connections.length" class="widget-empty">No saved vCenter systems configured. The legacy env/settings default can still be used by import.</p>
<p v-if="!connections.length" class="widget-empty">No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.</p>
</section>
</div>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update vCenter system' : 'New vCenter system'"
:title="form.id ? 'Update VMware connection' : 'New VMware connection'"
eyebrow="CONFIG / VMWARE"
description="Store vCenter API credentials encrypted at rest. These connections are selectable during host import."
description="Store VMware credentials encrypted at rest. vCenter systems can import VM inventory; standalone hosts use the vSphere Web Services API for direct connection validation."
wide
@close="modalOpen = false"
>
<form class="form-grid" @submit.prevent="save">
<label><span>Name</span><input v-model="form.name" required placeholder="Production vCenter" /></label>
<label><span>Base URL</span><input v-model="form.baseUrl" required placeholder="https://vcenter.contoso.local" /></label>
<label><span>Username</span><input v-model="form.username" required placeholder="administrator@vsphere.local" /></label>
<label>
<span>Connection type</span>
<select v-model="form.targetType">
<option value="vcenter">vCenter inventory</option>
<option value="host">Standalone ESXi host</option>
</select>
</label>
<label><span>Name</span><input v-model="form.name" required :placeholder="form.targetType === 'host' ? 'Rack ESXi host 01' : 'Production vCenter'" /></label>
<label><span>Base URL</span><input v-model="form.baseUrl" required :placeholder="baseUrlPlaceholder" /></label>
<label><span>Username</span><input v-model="form.username" required :placeholder="form.targetType === 'host' ? 'root or delegated ESXi account' : 'administrator@vsphere.local'" /></label>
<label><span>{{ form.id ? 'Replace password' : 'Password' }}</span><input v-model="form.password" :required="!form.id" type="password" placeholder="Stored encrypted after save" /></label>
<label><span>API mode</span><select v-model="form.apiMode"><option value="auto">Auto</option><option value="api">vSphere /api</option><option value="rest">Legacy /rest</option></select></label>
<label>
<span>API mode</span>
<select v-model="form.apiMode" :disabled="form.targetType === 'host'">
<option value="auto">Auto</option>
<option value="api">vSphere /api</option>
<option value="rest">Legacy /rest</option>
<option value="web-services">vSphere Web Services</option>
</select>
</label>
<label><span>Timeout (ms)</span><input v-model.number="form.requestTimeoutMs" type="number" min="1000" max="120000" /></label>
<label><span>Visibility</span><select v-model="form.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>Allow this system to be used for import and power-state checks.</small></span></label>
<label class="toggle modal-toggle"><input v-model="form.allowUntrustedTls" type="checkbox" /><span><strong>Allow untrusted TLS</strong><small>Use only for internal/self-signed vCenter certificates.</small></span></label>
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>{{ enabledHelpText }}</small></span></label>
<label class="toggle modal-toggle"><input v-model="form.allowUntrustedTls" type="checkbox" /><span><strong>Allow untrusted TLS</strong><small>Use only for internal/self-signed VMware certificates.</small></span></label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />Save vCenter</button>
<button class="ghost-button" type="button" @click="modalOpen = false">
<i class="mdi mdi-close" aria-hidden="true"></i>Cancel
</button>
<button class="primary-action" type="submit">
<i class="mdi mdi-content-save-outline" aria-hidden="true"></i>Save VMware connection
</button>
</div>
</form>
</BaseModal>
@@ -66,8 +93,7 @@
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { CloudCog, Save } from '@lucide/vue';
import { computed, reactive, ref, watch } from 'vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
@@ -80,12 +106,27 @@ const emit = defineEmits(['save', 'delete', 'test']);
const modalOpen = ref(false);
const form = reactive(defaultForm());
const enabledCount = computed(() => props.connections.filter((connection) => connection.enabled).length);
const vcenterCount = computed(() => props.connections.filter((connection) => connection.targetType !== 'host').length);
const standaloneCount = computed(() => props.connections.filter((connection) => connection.targetType === 'host').length);
const testedCount = computed(() => props.connections.filter((connection) => connection.lastTestStatus).length);
const baseUrlPlaceholder = computed(() => form.targetType === 'host' ? 'https://esxi01.contoso.local' : 'https://vcenter.contoso.local');
const enabledHelpText = computed(() => form.targetType === 'host'
? 'Allow this standalone host connection to be tested and used for direct VMware host workflows.'
: 'Allow this vCenter system to be used for import and power-state checks.');
watch(() => form.targetType, (targetType) => {
if (targetType === 'host') {
form.apiMode = 'web-services';
} else if (form.apiMode === 'web-services') {
form.apiMode = 'auto';
}
});
function defaultForm() {
return {
id: null,
name: '',
targetType: 'vcenter',
baseUrl: '',
username: '',
password: '',
@@ -100,6 +141,8 @@ function defaultForm() {
function openModal(connection = null) {
Object.assign(form, defaultForm(), connection || {});
form.targetType = connection?.targetType || 'vcenter';
form.apiMode = form.targetType === 'host' ? 'web-services' : (connection?.apiMode || 'auto');
form.password = '';
form.groupId = connection?.groupId || '';
modalOpen.value = true;
@@ -109,4 +152,8 @@ function save() {
emit('save', { ...form, groupId: form.groupId || null });
modalOpen.value = false;
}
function connectionTypeLabel(connection) {
return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory';
}
</script>

View File

@@ -21,6 +21,15 @@
</button>
</label>
<template v-if="kind === 'host'">
<label>
<span>OS</span>
<select v-model="osFamily">
<option value="">All OS types</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="other">Other</option>
</select>
</label>
<label>
<span>Source</span>
<select v-model="source">
@@ -87,6 +96,7 @@
<small>{{ secondaryText(item) }}</small>
</template>
<template v-else-if="column.key === 'address'"><code>{{ item.address || '-' }}</code></template>
<template v-else-if="column.key === 'osFamily'"><span :class="['status-pill', item.osFamily || 'other']">{{ osFamilyLabel(item.osFamily) }}</span></template>
<template v-else-if="column.key === 'transport'"><span class="status-pill neutral">{{ item.transport || '-' }}</span></template>
<template v-else-if="column.key === 'sourceType'"><span :class="['status-pill', item.sourceType === 'vmware' ? 'dynamic' : 'manual']">{{ sourceLabel(item.sourceType) }}</span></template>
<template v-else-if="column.key === 'mode'"><span :class="['status-pill', item.mode]">{{ item.mode || '-' }}</span></template>
@@ -141,6 +151,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
const search = ref('');
const osFamily = ref('');
const source = ref('');
const transport = ref('');
const selectedOnly = ref(false);
@@ -161,6 +172,7 @@ const columns = computed(() => props.kind === 'group'
: [
{ key: 'name', label: 'Host' },
{ key: 'address', label: 'Address' },
{ key: 'osFamily', label: 'OS' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'tags', label: 'Tags', sortable: false }
@@ -172,12 +184,13 @@ const filteredItems = computed(() => {
const selected = new Set(selectedIds.value);
return props.items.filter((item) => {
if (selectedOnly.value && !selected.has(item.id)) return false;
if (props.kind === 'host' && osFamily.value && (item.osFamily || 'other') !== osFamily.value) return false;
if (props.kind === 'host' && source.value && (item.sourceType || 'manual') !== source.value) return false;
if (props.kind === 'host' && transport.value && item.transport !== transport.value) return false;
if (!term) return true;
const values = props.kind === 'group'
? [item.name, item.mode, item.description, groupRuleSummary(item)]
: [item.name, item.fqdn, item.address, item.transport, item.sourceType, ...(item.tags || [])];
: [item.name, item.fqdn, item.address, item.osFamily, item.transport, item.sourceType, ...(item.tags || [])];
return values.filter(Boolean).some((value) => String(value).toLowerCase().includes(term));
}).sort((a, b) => {
const result = sortableValue(a, sort.value).localeCompare(sortableValue(b, sort.value), undefined, { numeric: true, sensitivity: 'base' });
@@ -188,7 +201,7 @@ const pageCount = computed(() => Math.max(1, Math.ceil(filteredItems.value.lengt
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, (page.value - 1) * pageSize.value + pageSize.value));
const pageAllSelected = computed(() => Boolean(pagedItems.value.length) && pagedItems.value.every((item) => selectedIds.value.includes(item.id)));
watch([search, source, transport, selectedOnly, pageSize], () => {
watch([search, osFamily, source, transport, selectedOnly, pageSize], () => {
page.value = 1;
});
@@ -235,6 +248,7 @@ function uniqueOptions(values) {
}
function sortableValue(item, key) {
if (key === 'osFamily') return item.osFamily || 'other';
if (key === 'sourceType') return item.sourceType || 'manual';
return String(item[key] || '');
}
@@ -249,6 +263,12 @@ function sourceLabel(value = 'manual') {
return value === 'vmware' ? 'VMware' : 'Manual';
}
function osFamilyLabel(value = 'other') {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
function groupRuleSummary(item) {
if (item.mode === 'manual') return 'Manual membership';
return (item.rules || []).map((rule) => `${rule.field} ${rule.operator} ${rule.value}`).join(` ${item.matchMode || 'all'} `) || 'No rules';

View File

@@ -4024,6 +4024,28 @@ textarea:focus {
color: var(--cyan);
}
.compatibility-callout {
padding: 12px 14px;
border: 1px solid rgba(255, 196, 87, .24);
border-radius: 15px;
background:
linear-gradient(135deg, rgba(255, 196, 87, .13), rgba(88, 221, 255, .045)),
rgba(8, 13, 30, .72);
}
.compatibility-callout strong {
display: block;
color: #ffe7a6;
font: 800 12px var(--display-font);
}
.compatibility-callout p {
margin: 5px 0 0;
color: rgba(226, 237, 250, .78);
font-size: 12px;
line-height: 1.45;
}
.script-test-output {
min-height: 360px;
display: grid;
@@ -4678,6 +4700,111 @@ body {
flex-wrap: wrap;
}
.material-icon-button {
width: 34px;
height: 34px;
min-width: 34px;
padding: 0;
border: 1px solid rgba(156, 199, 232, .16);
border-radius: 11px;
display: inline-grid;
place-items: center;
color: rgba(220, 237, 255, .84);
background:
linear-gradient(180deg, rgba(255, 255, 255, .09), rgba(255, 255, 255, .035)),
rgba(6, 15, 30, .62);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055);
}
.material-icon-button:hover {
color: #fff;
border-color: rgba(88, 221, 255, .42);
background:
radial-gradient(circle at 24% 0, rgba(88, 221, 255, .18), transparent 42%),
rgba(8, 18, 38, .82);
}
.primary-action i,
.ghost-button i,
.material-icon-button i {
flex: 0 0 auto;
font-size: 16px;
line-height: 1;
}
.host-group-summary-modal {
display: grid;
gap: 14px;
}
.summary-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.summary-metrics span {
min-height: 74px;
padding: 13px;
border: 1px solid rgba(156, 199, 232, .13);
border-radius: 16px;
display: grid;
align-content: center;
gap: 5px;
background:
radial-gradient(circle at 12% 0, rgba(88, 221, 255, .12), transparent 40%),
linear-gradient(145deg, rgba(15, 32, 58, .74), rgba(5, 12, 28, .7));
}
.summary-metrics strong {
color: #fff;
font-size: 22px;
line-height: 1;
}
.summary-metrics small {
color: rgba(190, 215, 238, .72);
font: 800 9px/1.2 var(--display-font);
letter-spacing: .08em;
text-transform: uppercase;
}
.summary-rule-panel {
padding: 14px;
border: 1px solid rgba(156, 199, 232, .13);
border-radius: 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .07), rgba(166, 122, 255, .055)),
rgba(4, 12, 28, .58);
}
.summary-rule-panel h4 {
margin: 3px 0 5px;
color: rgba(248, 251, 255, .96);
font-size: 15px;
}
.summary-rule-panel p {
margin: 0;
color: rgba(206, 222, 240, .74);
font-size: 12px;
}
.summary-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
}
.readonly-members {
max-height: 420px;
overflow: auto;
}
.vault-state {
display: inline-flex;
align-items: center;
@@ -4775,7 +4902,7 @@ body {
.member-toolbar {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(150px, 190px) minmax(150px, 190px) auto;
grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(120px, 160px)) auto;
gap: 10px;
align-items: end;
}
@@ -4803,7 +4930,8 @@ body {
position: relative;
}
.member-search svg {
.member-search svg,
.member-search > i {
position: absolute;
left: 13px;
bottom: 13px;
@@ -5000,6 +5128,16 @@ body {
}
@media (max-width: 720px) {
.summary-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-rule-panel,
.summary-actions {
align-items: stretch;
flex-direction: column;
}
.member-browser-header,
.member-results-bar,
.member-pagination {
@@ -5116,6 +5254,10 @@ body {
.status-pill.warn,
.status-pill.running { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); }
.status-pill.windows { color: #8be0ff; background: rgba(88, 221, 255, .1); border-color: rgba(88, 221, 255, .24); }
.status-pill.linux { color: #64e7bd; background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .24); }
.status-pill.other { color: rgba(226, 237, 250, .72); background: rgba(156, 199, 232, .08); border-color: rgba(156, 199, 232, .18); }
.pagination {
display: flex;
align-items: center;

View File

@@ -82,7 +82,7 @@ export async function previewVCenterImport(req, res) {
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
try {
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' });
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'VMware connection not found' });
res.json(await previewVCenterHosts({ ...parsed.data, connection }));
} catch (err) {
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
@@ -94,7 +94,7 @@ export async function importVCenterHosts(req, res) {
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
try {
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' });
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'VMware connection not found' });
const preview = await previewVCenterHosts({ ...parsed.data, connection });
const selected = new Set(parsed.data.vmIds || []);
const candidates = selected.size
@@ -125,16 +125,16 @@ export function listVCenterConnectionRecords(req, res) {
export function createVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' });
if (!parsed.data.password) return res.status(400).json({ error: 'A vCenter password is required when creating a connection' });
if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' });
if (!parsed.data.password) return res.status(400).json({ error: 'A VMware password is required when creating a connection' });
res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
}
export function updateVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' });
if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' });
const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id);
if (!connection) return res.status(404).json({ error: 'vCenter connection not found' });
if (!connection) return res.status(404).json({ error: 'VMware connection not found' });
res.json(connection);
}
@@ -145,7 +145,7 @@ export function deleteVCenterConnectionRecord(req, res) {
export async function testVCenterConnectionRecord(req, res) {
const connection = getVCenterConnection(req.params.connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'vCenter connection not found' });
if (!connection) return res.status(404).json({ error: 'VMware connection not found' });
try {
const result = await testVCenterConnection(connection);
recordVCenterConnectionTest(req.params.connectionId, 'success', result.message);

View File

@@ -1,4 +1,4 @@
import { folderSchema, scriptTestRunSchema } from '../forms/schemas.js';
import { folderSchema, scriptCompatibilitySchema, scriptTestRunSchema } from '../forms/schemas.js';
import {
cloneScript,
createFolder,
@@ -15,6 +15,7 @@ import {
} from '../models/libraryModel.js';
import { camelJob } from '../models/mappers.js';
import { executeScriptTest } from '../services/powershellRunner.js';
import { analyzeScriptTargetCompatibility } from '../services/powershellCompatibilityService.js';
export function folders(req, res) {
res.json(listFolders(req.user.id));
@@ -84,6 +85,14 @@ export function scriptUsageAction(req, res) {
res.json(usage);
}
export function scriptCompatibilityAction(req, res) {
const parsed = scriptCompatibilitySchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid compatibility payload' });
const result = analyzeScriptTargetCompatibility(req.params.id, parsed.data, req.user.id);
if (!result) return res.status(404).json({ error: 'Script not found' });
res.json(result);
}
export function testRunScriptAction(req, res) {
const parsed = scriptTestRunSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' });

View File

@@ -1,6 +1,7 @@
import { camelJob } from '../models/mappers.js';
import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js';
import { executeRunPlan } from '../services/powershellRunner.js';
import { analyzeRunPlanCompatibility } from '../services/powershellCompatibilityService.js';
export function index(req, res) {
res.json(listRunPlans(req.user.id));
@@ -37,3 +38,9 @@ export function execute(req, res) {
res.status(400).json({ error: error.message });
}
}
export function compatibility(req, res) {
const result = analyzeRunPlanCompatibility(req.params.id, req.user.id);
if (!result) return res.status(404).json({ error: 'RunPlan not found' });
res.json(result);
}

View File

@@ -62,7 +62,14 @@ const operationsArticles = [
article('ops-hosts-runplans', 'Operations', 'Hosts, Credential Vault, RunPlans, And Jobs', 'Manage target systems and execute scripts safely.', [
section('Hosts and credentials', [
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.'
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
]),
section('Host Groups and VMware sources', [
'Host Groups are target collections. The table supports search, sort, pagination, a read-only summary view, and CSV/XLS exports of assigned hostnames, IP addresses, and VMware source system.',
'Config / VMware supports two connection types: vCenter inventory systems and standalone ESXi hosts. vCenter systems can import VM inventory and run pre-execution power-state checks.',
'Standalone ESXi host connections use the vSphere Web Services API for direct host validation. They are not vCenter inventory sources, so they cannot be selected in the VM import wizard.'
]),
section('RunPlans', [
'A RunPlan joins one script with one or more hosts. The backend creates a job and host-level log rows during execution.',
@@ -116,11 +123,18 @@ const apiArticles = [
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
apiExample('Post', '/api/runplans/rp_123/execute', 'Execute a RunPlan and create a job. Replace rp_123 with the RunPlan id.'),
apiExample('Get', '/api/jobs', 'List job history.'),
apiExample('Get', '/api/jobs/job_123', 'Read one job with host rows and logs.'),
apiExample('Get', '/api/logs/system', 'Read system/request logs from the Winston log stream.')
,
section('VMware connection payloads', [
'Saved VMware connection records use targetType = vcenter for inventory import/power-state checks or targetType = host for a standalone ESXi host connection.',
'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Inventory preview/import requires a vCenter connection.'
], '@{\n name = "Production vCenter"\n targetType = "vcenter"\n baseUrl = "https://vcenter.contoso.local"\n username = "administrator@vsphere.local"\n password = "secret"\n apiMode = "auto"\n requestTimeoutMs = 20000\n allowUntrustedTls = $false\n enabled = $true\n visibility = "shared"\n}\n\n@{\n name = "Standalone ESXi 01"\n targetType = "host"\n baseUrl = "https://esxi01.contoso.local"\n username = "root"\n password = "secret"\n apiMode = "web-services"\n requestTimeoutMs = 20000\n allowUntrustedTls = $true\n enabled = $true\n visibility = "shared"\n}')
], ['api', 'credentials', 'hosts', 'runplans', 'jobs']),
article('api-psadt', 'API', 'PSADT And Intune API', 'Use the PSADT catalog, verifier, migration wizard, profiles, and Intune publishing plans through API calls.', [
apiExample('Get', '/api/psadt/catalog', 'Return PSADT source links, module/function/ADMX catalogs, snippets, and Intune publishing guidance.'),
@@ -229,6 +243,24 @@ const cmdletArticles = [
]),
section('Discovery examples', [], 'Get-Help Invoke-Command -Detailed\nGet-Command -Module Microsoft.PowerShell.Core\nGet-Command *PSSession*\nUpdate-Help -Force')
], ['powershell', 'cmdlets', 'reference']),
article('powershell-linux-compatibility', 'PowerShell', 'Linux Compatibility Checks', 'Understand how POSHManager warns about scripts that may not work on Linux targets.', [
section('Host OS type', [
'Hosts carry osFamily metadata: windows, linux, or other. The UI shows this in Hosts, Host Groups, RunPlan target pickers, and script test target summaries.',
'Use Other when the OS is unknown. POSHManager only runs Linux compatibility warnings when at least one selected target is marked Linux.'
]),
section('Common findings', [
'Registry providers such as HKLM: and HKCU: are Windows-specific.',
'Drive paths such as C:\\Temp and UNC paths assume Windows filesystem semantics.',
'Windows executables such as msiexec.exe, reg.exe, cmd.exe, and wmic.exe are not expected on Linux.',
'WMI and Win32_* classes are Windows-specific inventory patterns.',
'Aliases should be avoided in reusable scripts; use full command names such as Get-ChildItem, Where-Object, and ForEach-Object.'
]),
section('Runtime behavior', [
'Script Test / Run and RunPlan execution call the compatibility preflight before queueing. If warnings are found, the UI asks for confirmation.',
'The runner also writes compatibility findings into Linux target job logs so API-driven execution receives the same operational evidence.',
'POSHM_HOST_OS_FAMILY is injected during execution so scripts can branch with the stored host OS type.'
])
], ['linux', 'compatibility', 'powershell', 'hosts']),
article('cmdlets-remoting', 'PowerShell', 'PowerShell Remoting Cmdlets', 'Useful cmdlets for WinRM/PSSession workflows used around POSHManager.', [
section('Session workflow', [
'Enable-PSRemoting configures a computer to receive remote commands. This must be done on targets outside POSHManager.',

View File

@@ -108,6 +108,7 @@ export function migrate() {
secret_cipher TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT 'vcenter',
api_mode TEXT NOT NULL DEFAULT 'auto',
request_timeout_ms INTEGER NOT NULL DEFAULT 20000,
allow_untrusted_tls INTEGER NOT NULL DEFAULT 0,
@@ -511,6 +512,9 @@ export function migrate() {
ensureColumn('hosts', 'source_type', "TEXT NOT NULL DEFAULT 'manual'");
ensureColumn('hosts', 'source_connection_id', 'TEXT');
ensureColumn('hosts', 'source_ref', 'TEXT');
// VMware connection type separates vCenter inventory/power-state APIs from
// standalone ESXi host checks that use the vSphere Web Services SOAP API.
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
// read-only connections can never be used to change a tenant by accident.
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');

View File

@@ -1,5 +1,6 @@
import { z } from 'zod';
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
export const loginSchema = z.object({
email: z.string().email(),
@@ -53,7 +54,7 @@ export const hostSchema = z.object({
name: z.string().min(1),
fqdn: z.string().optional(),
address: z.string().min(1),
osFamily: z.string().default('windows'),
osFamily: z.preprocess((value) => normalizeOsFamily(value, 'windows'), z.enum(['windows', 'linux', 'other'])).default('windows'),
transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
port: z.number().int().nullable().optional(),
credentialId: z.string().nullable().optional(),
@@ -68,10 +69,11 @@ export const hostSchema = z.object({
export const vcenterConnectionSchema = z.object({
name: z.string().min(1).max(120),
targetType: z.enum(['vcenter', 'host']).optional().default('vcenter'),
baseUrl: z.string().url(),
username: z.string().min(1).max(200),
password: z.string().optional().default(''),
apiMode: z.enum(['auto', 'api', 'rest']).optional().default('auto'),
apiMode: z.enum(['auto', 'api', 'rest', 'web-services']).optional().default('auto'),
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional().default(20000),
allowUntrustedTls: z.boolean().optional().default(false),
enabled: z.boolean().optional().default(true),
@@ -364,6 +366,13 @@ const runPlanPayloadSchema = z.object({
hostGroupIds: z.array(z.string()).optional()
});
export const scriptCompatibilitySchema = z.object({
hostId: z.string().optional().default(''),
hostGroupId: z.string().optional().default(''),
hostIds: z.array(z.string()).optional().default([]),
hostGroupIds: z.array(z.string()).optional().default([])
});
export function normalizeScriptPayload(body) {
const parsed = scriptPayloadSchema.parse(body);
return {

View File

@@ -1,5 +1,6 @@
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
import { camelHost } from './mappers.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
export function listHosts(userId) {
return db.prepare(`
@@ -29,7 +30,7 @@ export function createHost(payload, userId) {
payload.name,
payload.fqdn || '',
payload.address,
payload.osFamily,
normalizeOsFamily(payload.osFamily, 'windows'),
payload.transport,
payload.port || null,
payload.credentialId || null,
@@ -80,7 +81,7 @@ export function updateHost(hostId, payload, userId) {
payload.name || existing.name,
payload.fqdn ?? existing.fqdn,
payload.address || existing.address,
payload.osFamily || existing.os_family,
normalizeOsFamily(payload.osFamily ?? existing.os_family, 'windows'),
payload.transport || existing.transport,
payload.port ?? existing.port,
payload.credentialId ?? existing.credential_id,

View File

@@ -1,4 +1,5 @@
import { parseJson } from '../db.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
export function camelGroup(row) {
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at };
@@ -26,7 +27,7 @@ export function camelHost(row) {
name: row.name,
fqdn: row.fqdn,
address: row.address,
osFamily: row.os_family,
osFamily: normalizeOsFamily(row.os_family, 'other'),
transport: row.transport,
port: row.port,
credentialId: row.credential_id,
@@ -53,6 +54,7 @@ export function camelVCenterConnection(row) {
baseUrl: row.base_url,
username: row.username,
hasPassword: Boolean(row.secret_cipher),
targetType: row.target_type || 'vcenter',
apiMode: row.api_mode || 'auto',
requestTimeoutMs: row.request_timeout_ms || 20000,
allowUntrustedTls: Boolean(row.allow_untrusted_tls),

View File

@@ -9,8 +9,14 @@ function normalizeConnection(body, existing = {}) {
...body,
password: body.password || ''
});
const targetType = parsed.targetType || 'vcenter';
const apiMode = targetType === 'host'
? 'web-services'
: (parsed.apiMode === 'web-services' ? 'auto' : parsed.apiMode);
return {
...parsed,
targetType,
apiMode,
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
@@ -52,10 +58,10 @@ export function createVCenterConnection(body, userId) {
db.prepare(`
INSERT INTO vcenter_connections (
id, name, base_url, username, secret_cipher, secret_iv, secret_tag,
api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
target_type, api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
connectionId,
payload.name,
@@ -64,6 +70,7 @@ export function createVCenterConnection(body, userId) {
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.targetType,
payload.apiMode,
payload.requestTimeoutMs,
payload.allowUntrustedTls ? 1 : 0,
@@ -85,6 +92,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
name: existing.name,
baseUrl: existing.base_url,
username: existing.username,
targetType: existing.target_type || 'vcenter',
apiMode: existing.api_mode,
requestTimeoutMs: existing.request_timeout_ms,
allowUntrustedTls: Boolean(existing.allow_untrusted_tls),
@@ -100,7 +108,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
db.prepare(`
UPDATE vcenter_connections
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
target_type = ?, api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
@@ -110,6 +118,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.targetType,
payload.apiMode,
payload.requestTimeoutMs,
payload.allowUntrustedTls ? 1 : 0,

View File

@@ -7,6 +7,7 @@ import {
deleteScriptAction,
folders,
getScriptAction,
scriptCompatibilityAction,
scriptUsageAction,
scripts,
scriptVersions,
@@ -28,6 +29,7 @@ folderRoutes.delete('/:id', requireAuth, deleteFolderAction);
scriptRoutes.get('/', requireAuth, scripts);
scriptRoutes.post('/', requireAuth, createScriptAction);
scriptRoutes.get('/:id/usage', requireAuth, scriptUsageAction);
scriptRoutes.post('/:id/compatibility', requireAuth, scriptCompatibilityAction);
scriptRoutes.post('/:id/clone', requireAuth, cloneScriptAction);
scriptRoutes.post('/:id/test-run', requireAuth, testRunScriptAction);
scriptRoutes.get('/:id', requireAuth, getScriptAction);

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import { create, destroy, execute, index, show, update } from '../controllers/runPlanController.js';
import { compatibility, create, destroy, execute, index, show, update } from '../controllers/runPlanController.js';
import { requireAuth } from '../middleware/auth.js';
export const runPlanRoutes = Router();
@@ -8,6 +8,7 @@ export const runPlanRoutes = Router();
runPlanRoutes.get('/', requireAuth, index);
runPlanRoutes.post('/', requireAuth, create);
runPlanRoutes.get('/:id', requireAuth, show);
runPlanRoutes.get('/:id/compatibility', requireAuth, compatibility);
runPlanRoutes.put('/:id', requireAuth, update);
runPlanRoutes.delete('/:id', requireAuth, destroy);
runPlanRoutes.post('/:id/execute', requireAuth, execute);

View File

@@ -0,0 +1,334 @@
import { normalizeOsFamily } from '../utils/osFamily.js';
import { db } from '../db.js';
const WINDOWS_EXECUTABLES = [
'cmd.exe',
'powershell.exe',
'wmic.exe',
'msiexec.exe',
'reg.exe',
'sc.exe',
'net.exe',
'netsh.exe',
'schtasks.exe',
'whoami.exe',
'gpupdate.exe'
];
const ALIASES_TO_AVOID = {
'%': 'ForEach-Object',
'?': 'Where-Object',
cat: 'Get-Content',
cd: 'Set-Location',
cls: 'Clear-Host',
copy: 'Copy-Item',
cp: 'Copy-Item',
del: 'Remove-Item',
dir: 'Get-ChildItem',
echo: 'Write-Output',
erase: 'Remove-Item',
h: 'Get-History',
history: 'Get-History',
kill: 'Stop-Process',
ls: 'Get-ChildItem',
md: 'mkdir',
move: 'Move-Item',
mv: 'Move-Item',
popd: 'Pop-Location',
ps: 'Get-Process',
pushd: 'Push-Location',
pwd: 'Get-Location',
r: 'Invoke-History',
rd: 'Remove-Item',
ren: 'Rename-Item',
rm: 'Remove-Item',
rmdir: 'Remove-Item',
sl: 'Set-Location',
sort: 'Sort-Object',
type: 'Get-Content',
where: 'Where-Object'
};
const RULES = [
{
id: 'windows-registry-provider',
level: 'warning',
title: 'Windows registry provider usage',
pattern: /\b(?:HKLM:|HKCU:|HKCR:|HKU:|Registry::|SOFTWARE\\|SYSTEM\\CurrentControlSet\\)/i,
message: 'The script references Windows registry providers or registry paths, which are not available on Linux targets.',
remediation: 'Guard registry logic with $IsWindows or split the script by OS target.'
},
{
id: 'windows-drive-or-unc-path',
level: 'warning',
title: 'Windows filesystem path usage',
pattern: /(?:\b[A-Za-z]:\\|\\\\[A-Za-z0-9_.-]+\\)/,
message: 'The script contains Windows drive or UNC paths. Linux PowerShell uses Linux paths and case-sensitive filesystems.',
remediation: 'Use Join-Path and OS-aware path roots, or branch path logic with $IsWindows/$IsLinux.'
},
{
id: 'windows-environment-variable',
level: 'warning',
title: 'Windows environment variable usage',
pattern: /\$env:(?:ProgramFiles|ProgramFiles\(x86\)|SystemRoot|WINDIR|COMPUTERNAME|USERPROFILE|APPDATA|LOCALAPPDATA)\b/i,
message: 'The script references Windows-specific environment variables that are usually absent on Linux.',
remediation: 'Use cross-platform variables such as $HOME where possible, or provide Linux-specific fallbacks.'
},
{
id: 'wmi-or-win32-class',
level: 'warning',
title: 'WMI or Win32 class usage',
pattern: /\b(?:Get-WmiObject|gwmi|Win32_[A-Za-z0-9_]+|root\\cimv2)\b/i,
message: 'The script uses WMI/Win32 inventory patterns that are Windows-specific and commonly fail on Linux.',
remediation: 'Use cross-platform PowerShell/.NET APIs, SSH-native commands, or OS-specific branches.'
},
{
id: 'windows-service-or-eventlog-cmdlet',
level: 'warning',
title: 'Windows service or event log cmdlet usage',
pattern: /\b(?:Get-EventLog|New-EventLog|Write-EventLog|Get-WinEvent|Get-Service|Set-Service|Restart-Service)\b/i,
message: 'This cmdlet often targets Windows service or event-log subsystems and may not behave the same on Linux hosts.',
remediation: 'Validate Linux behavior explicitly or wrap the command in an OS-specific branch.'
},
{
id: 'windows-executable',
level: 'warning',
title: 'Windows executable usage',
pattern: new RegExp(`\\b(?:${WINDOWS_EXECUTABLES.map(escapeRegExp).join('|')})\\b`, 'i'),
message: 'The script launches Windows executables that are not expected to exist on Linux targets.',
remediation: 'Replace with PowerShell-native commands, Linux equivalents, or target Windows hosts only.'
}
];
export function analyzePowerShellCompatibility(content, targets = []) {
const targetOsFamilies = [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family)).filter(Boolean))];
const hasLinuxTargets = targetOsFamilies.includes('linux');
const findings = [];
if (!hasLinuxTargets) {
return {
targetOsFamilies,
hasLinuxTargets,
findings,
summary: 'No Linux targets selected.'
};
}
const text = String(content || '');
const lines = text.split(/\r?\n/);
for (const rule of RULES) {
const match = findRuleMatch(lines, rule.pattern);
if (match) findings.push(toFinding(rule, match));
}
for (const match of findAliasMatches(lines).slice(0, 6)) {
findings.push({
id: `alias-${match.alias}`,
level: 'info',
title: `Alias "${match.alias}" used`,
message: `Alias "${match.alias}" maps to ${match.command}. Aliases can be profile or host dependent, so they are risky in scripts that run across Windows and Linux.`,
remediation: `Use the full command name ${match.command}.`,
line: match.line,
excerpt: match.excerpt,
source: 'Microsoft PowerShell alias guidance'
});
}
if (!findings.length) {
findings.push({
id: 'linux-ready',
level: 'ok',
title: 'No obvious Linux blockers detected',
message: 'Static analysis did not find common Windows-only patterns. Runtime behavior still depends on installed modules and host configuration.',
remediation: 'Run a controlled test against a Linux host before broad execution.',
line: null,
excerpt: '',
source: 'POSHManager compatibility analyzer'
});
}
return {
targetOsFamilies,
hasLinuxTargets,
findings,
summary: `${findings.filter((finding) => finding.level === 'warning').length} warning(s), ${findings.filter((finding) => finding.level === 'info').length} note(s) for Linux targets.`
};
}
export function analyzeScriptTargetCompatibility(scriptId, payload, userId) {
const script = db.prepare(`
SELECT *
FROM scripts
WHERE id = ? AND (
visibility = 'shared'
OR owner_user_id = ?
OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(scriptId, userId, userId);
if (!script) return null;
const targets = resolveVisibleCompatibilityTargets(payload, userId);
return {
scriptId: script.id,
scriptName: script.name,
targets: summarizeTargets(targets),
...analyzePowerShellCompatibility(script.content, targets)
};
}
export function analyzeRunPlanCompatibility(runplanId, userId) {
const runplan = db.prepare(`
SELECT r.*, s.content AS script_content, s.name AS script_name
FROM runplans r
JOIN scripts s ON s.id = r.script_id
WHERE r.id = ? AND (
r.visibility = 'shared'
OR r.owner_user_id = ?
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(runplanId, userId, userId);
if (!runplan) return null;
const targets = resolveRunPlanTargets(runplanId);
return {
runplanId,
scriptId: runplan.script_id,
scriptName: runplan.script_name,
targets: summarizeTargets(targets),
...analyzePowerShellCompatibility(runplan.script_content, targets)
};
}
export function compatibilityLogLines(analysis) {
if (!analysis?.hasLinuxTargets) return [];
return [
`Linux target compatibility preflight: ${analysis.summary}`,
...analysis.findings
.filter((finding) => finding.level !== 'ok')
.slice(0, 10)
.map((finding) => `${finding.level.toUpperCase()}: ${finding.title}${finding.line ? ` on line ${finding.line}` : ''}. ${finding.message} ${finding.remediation}`)
];
}
function findRuleMatch(lines, pattern) {
for (const [index, line] of lines.entries()) {
if (pattern.test(line)) return { line: index + 1, excerpt: line.trim().slice(0, 260) };
}
return null;
}
function toFinding(rule, match) {
return {
id: rule.id,
level: rule.level,
title: rule.title,
message: rule.message,
remediation: rule.remediation,
line: match.line,
excerpt: match.excerpt,
source: 'PowerShell cross-platform compatibility'
};
}
function findAliasMatches(lines) {
const matches = [];
const aliases = Object.keys(ALIASES_TO_AVOID).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|');
const commandPattern = new RegExp(`^\\s*(?:&\\s*)?(${aliases})(?:\\s|$|\\|)`, 'i');
const pipelinePattern = new RegExp(`\\|\\s*(${aliases})(?:\\s|$|\\|)`, 'i');
for (const [index, line] of lines.entries()) {
const stripped = line.replace(/#.*/, '');
const match = stripped.match(commandPattern) || stripped.match(pipelinePattern);
if (!match) continue;
const alias = match[1].toLowerCase();
matches.push({
alias,
command: ALIASES_TO_AVOID[alias],
line: index + 1,
excerpt: line.trim().slice(0, 260)
});
}
return matches;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function resolveVisibleCompatibilityTargets(payload = {}, userId) {
const hostIds = [...new Set([payload.hostId, ...(payload.hostIds || [])].filter(Boolean))];
const groupIds = [...new Set([payload.hostGroupId, ...(payload.hostGroupIds || [])].filter(Boolean))];
const targets = [];
if (hostIds.length) {
targets.push(...db.prepare(`
SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport
FROM hosts h
WHERE h.id IN (${hostIds.map(() => '?').join(',')})
AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).all(...hostIds, userId, userId));
}
if (groupIds.length) {
targets.push(...db.prepare(`
SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport
FROM host_group_members hgm
JOIN host_groups hg ON hg.id = hgm.host_group_id
AND (
hg.visibility = 'shared'
OR hg.owner_user_id = ?
OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
JOIN hosts h ON h.id = hgm.host_id
AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
WHERE hgm.host_group_id IN (${groupIds.map(() => '?').join(',')})
`).all(userId, userId, userId, userId, ...groupIds));
}
return dedupeTargets(targets);
}
function resolveRunPlanTargets(runplanId) {
return dedupeTargets(db.prepare(`
SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport
FROM hosts h
WHERE h.id IN (
SELECT host_id FROM runplan_hosts WHERE runplan_id = ?
UNION
SELECT hgm.host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = ?
)
ORDER BY h.name
`).all(runplanId, runplanId));
}
function dedupeTargets(targets = []) {
const seen = new Set();
return targets.filter((target) => {
if (!target?.id || seen.has(target.id)) return false;
seen.add(target.id);
target.osFamily = normalizeOsFamily(target.osFamily || target.os_family, 'other');
return true;
});
}
function summarizeTargets(targets = []) {
return {
count: targets.length,
osFamilies: [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other')))],
linuxCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'linux').length,
windowsCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'windows').length,
otherCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'other').length,
sample: targets.slice(0, 8).map((target) => ({
id: target.id,
name: target.name,
address: target.address,
osFamily: normalizeOsFamily(target.osFamily || target.os_family, 'other'),
transport: target.transport
}))
};
}

View File

@@ -11,6 +11,8 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
import { getVCenterVmPowerState } from './vcenterService.js';
import { path } from '../utils/pathUtils.js';
import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
// flipped at runtime via the Config screen (allow_script_execution). The DB
@@ -179,6 +181,7 @@ export function executeScriptTest(scriptId, payload, userId) {
async function runJob(jobId, script, hosts, parallel, executionContext) {
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
logCompatibilityPreflight(jobId, script, hosts);
const runner = async (host) => runHost(jobId, script, host, executionContext);
if (parallel) {
await Promise.all(hosts.map(runner));
@@ -190,10 +193,20 @@ async function runJob(jobId, script, hosts, parallel, executionContext) {
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
}
function logCompatibilityPreflight(jobId, script, hosts) {
const analysis = analyzePowerShellCompatibility(script.content, hosts);
const lines = compatibilityLogLines(analysis);
if (!lines.length) return;
for (const host of hosts.filter((target) => normalizeOsFamily(target.os_family || target.osFamily, 'other') === 'linux')) {
for (const line of lines) appendLog(jobId, host.id, 'system', line);
}
}
async function runHost(jobId, script, host, executionContext) {
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id);
appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`);
appendLog(jobId, host.id, 'system', `Target OS type: ${normalizeOsFamily(host.os_family || host.osFamily, 'other')}`);
if (!scriptExecutionAllowed()) {
appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
@@ -372,6 +385,7 @@ function runtimeVariableValues(host, executionContext) {
{ name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' },
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
{ name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' },
{ name: 'POSHM_HOST_OS_FAMILY', value: normalizeOsFamily(host.os_family || host.osFamily, 'other'), valueType: 'string' },
{ name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
];

View File

@@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer';
import { Agent } from 'undici';
import { config } from '../config.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
const WINDOWS_HINTS = ['windows', 'microsoft'];
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
const MAX_TRACE_ENTRIES = 700;
const MAX_TRACE_BODY_CHARS = 6000;
const VM_ENRICHMENT_CONCURRENCY = 8;
const WEB_SERVICES_PROFILE_LABEL = 'vSphere Web Services API';
const API_PROFILES = {
api: {
mode: 'api',
@@ -45,6 +47,7 @@ export function getVCenterSettings() {
const requestTimeoutMs = Number(getStringSetting('vcenter_request_timeout_ms', String(config.vcenter.requestTimeoutMs || 20000)));
return {
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
targetType: 'vcenter',
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
username: getStringSetting('vcenter_username', config.vcenter.username),
password: getStringSetting('vcenter_password', config.vcenter.password),
@@ -57,12 +60,16 @@ export function getVCenterSettings() {
export function settingsFromVCenterConnection(connection) {
if (!connection) return getVCenterSettings();
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
const targetType = String(connection.target_type || connection.targetType || 'vcenter').toLowerCase() === 'host' ? 'host' : 'vcenter';
return {
enabled: Boolean(connection.enabled ?? true),
targetType,
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
username: connection.username || '',
password: connection.password || '',
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
apiMode: targetType === 'host'
? 'web-services'
: (['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto'),
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
};
@@ -77,6 +84,7 @@ export function vcenterStatus() {
return {
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
targetType: settings.targetType,
baseUrl: settings.baseUrl,
username: settings.username,
apiMode: settings.apiMode,
@@ -92,6 +100,7 @@ export function vcenterConnectionStatus(connection) {
name: connection?.name || 'Configured default',
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
targetType: settings.targetType,
baseUrl: settings.baseUrl,
username: settings.username,
apiMode: settings.apiMode,
@@ -101,9 +110,10 @@ export function vcenterConnectionStatus(connection) {
}
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.');
const label = settings.targetType === 'host' ? 'VMware standalone host' : 'VMware vCenter';
if (!settings.enabled) throw new Error(`${label} integration is disabled in Configuration.`);
if (!settings.baseUrl) throw new Error(`${label} base URL is missing.`);
if (!settings.username || !settings.password) throw new Error(`${label} credentials are missing.`);
}
function dispatcherFor(settings) {
@@ -224,6 +234,115 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
return payload;
}
function soapEndpoint(settings) {
return `${settings.baseUrl.replace(/\/sdk$/i, '')}/sdk`;
}
function soapEnvelope(body) {
return `<?xml version="1.0" encoding="UTF-8"?>` +
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:vim25="urn:vim25">` +
`<soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`;
}
function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function redactSoapBody(value) {
return String(value || '')
.replace(/(<password>)([\s\S]*?)(<\/password>)/gi, '$1[redacted]$3')
.replace(/(<userName>)([\s\S]*?)(<\/userName>)/gi, '$1[redacted username]$3');
}
function soapBodyPreview(value) {
const text = redactSoapBody(value);
return text.length > MAX_TRACE_BODY_CHARS
? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)`
: text;
}
function extractSoapTag(text, tagName) {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
const match = String(text || '').match(pattern);
return match ? match[1].trim() : '';
}
function extractSoapFault(text) {
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
}
async function vSphereSoapFetch(settings, action, body, trace = null) {
const url = soapEndpoint(settings);
const requestBody = soapEnvelope(body);
const headers = {
Accept: 'text/xml',
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"urn:vim25/${action}"`
};
const startedAt = Date.now();
let response;
let text = '';
try {
response = await fetch(url, {
method: 'POST',
headers,
body: requestBody,
dispatcher: dispatcherFor(settings),
signal: AbortSignal.timeout(settings.requestTimeoutMs)
});
text = await response.text();
} catch (err) {
pushTrace(trace, {
method: 'POST',
url,
soapAction: action,
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
requestBody: soapBodyPreview(requestBody),
durationMs: Date.now() - startedAt,
timeoutMs: settings.requestTimeoutMs,
networkError: err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP endpoint.`
: err.message
});
const message = err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP action ${action}.`
: `Unable to reach VMware host SOAP action ${action}: ${err.message}`;
throw new VCenterTraceError(message, trace);
}
pushTrace(trace, {
method: 'POST',
url,
soapAction: action,
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
requestBody: soapBodyPreview(requestBody),
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
responseHeaders: {
'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || ''
},
responseSummary: {
type: 'soap',
bytes: text.length,
fault: Boolean(extractSoapFault(text))
},
responseBody: soapBodyPreview(text)
});
const fault = extractSoapFault(text);
if (!response.ok || fault) {
throw new VCenterTraceError(`VMware host SOAP action ${action} failed${response.ok ? '' : ` with HTTP ${response.status}`}: ${fault || response.statusText}`, trace, response.status || 502);
}
return text;
}
function unwrapList(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.value)) return payload.value;
@@ -270,7 +389,7 @@ function inferOsFamily(identity = {}) {
const haystack = [identity.family, identity.name, localizedText(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';
return 'other';
}
function localizedText(value) {
@@ -313,7 +432,7 @@ function normalizeVCenterSummaryVm(vm) {
ipAddress: '',
ipAddresses: [],
powerState: vm.power_state || vm.powerState || '',
osFamily: 'windows',
osFamily: 'other',
guestFullName: '',
toolsAvailable: false,
source: 'vcenter'
@@ -380,7 +499,7 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: candidate.osFamily || 'windows',
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
@@ -407,6 +526,9 @@ export async function previewVCenterHosts(options = {}) {
} catch (err) {
throw new VCenterTraceError(err.message, trace, 400);
}
if (settings.targetType === 'host') {
throw new VCenterTraceError('Standalone VMware host connections use the vSphere Web Services API and cannot perform vCenter VM inventory imports. Choose a vCenter connection for VM discovery.', trace, 400);
}
const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
const allVms = unwrapList(vmPayload);
const summaryFilter = filterSummaryVms(allVms, options.filter);
@@ -452,6 +574,7 @@ export async function testVCenterConnection(connection) {
const trace = [];
const settings = settingsFromVCenterConnection(connection);
assertConfigured(settings);
if (settings.targetType === 'host') return testStandaloneHostConnection(connection, settings, trace);
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
return {
status: vcenterConnectionStatus(connection),
@@ -462,9 +585,39 @@ export async function testVCenterConnection(connection) {
};
}
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services',
endpoint: soapEndpoint(settings)
});
const serviceContent = await vSphereSoapFetch(
settings,
'RetrieveServiceContent',
`<vim25:RetrieveServiceContent><_this type="ServiceInstance">ServiceInstance</_this></vim25:RetrieveServiceContent>`,
trace
);
const sessionManager = extractSoapTag(serviceContent, 'sessionManager') || 'SessionManager';
await vSphereSoapFetch(
settings,
'Login',
`<vim25:Login><_this type="SessionManager">${xmlEscape(sessionManager)}</_this><userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></vim25:Login>`,
trace
);
return {
status: vcenterConnectionStatus(connection),
apiProfile: WEB_SERVICES_PROFILE_LABEL,
count: 0,
trace,
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}.`
};
}
export async function getVCenterVmPowerState(connection, vmId, trace = null) {
if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' };
const settings = settingsFromVCenterConnection(connection);
if (settings.targetType === 'host') {
return { checked: false, reason: 'Standalone VMware host connections do not provide vCenter VM power-state inventory lookups.' };
}
assertConfigured(settings);
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
let lastError = null;

View File

@@ -0,0 +1,35 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { analyzePowerShellCompatibility } = await load('../services/powershellCompatibilityService.js');
const { normalizeOsFamily } = await load('../utils/osFamily.js');
test('normalizeOsFamily maps legacy and unknown host values into supported OS types', () => {
assert.equal(normalizeOsFamily('Windows Server 2022'), 'windows');
assert.equal(normalizeOsFamily('Ubuntu Linux'), 'linux');
assert.equal(normalizeOsFamily('network'), 'other');
assert.equal(normalizeOsFamily('api'), 'other');
});
test('PowerShell compatibility analyzer is quiet when no Linux targets are selected', () => {
const result = analyzePowerShellCompatibility('Get-WmiObject Win32_OperatingSystem', [{ osFamily: 'windows' }]);
assert.equal(result.hasLinuxTargets, false);
assert.deepEqual(result.findings, []);
});
test('PowerShell compatibility analyzer flags Windows-only patterns for Linux targets', () => {
const result = analyzePowerShellCompatibility([
'Get-WmiObject Win32_OperatingSystem',
'Get-Item HKLM:\\Software\\Contoso',
'msiexec.exe /i C:\\Temp\\setup.msi',
'dir C:\\Temp'
].join('\n'), [{ osFamily: 'linux' }]);
assert.equal(result.hasLinuxTargets, true);
assert.ok(result.findings.some((finding) => finding.id === 'wmi-or-win32-class'));
assert.ok(result.findings.some((finding) => finding.id === 'windows-registry-provider'));
assert.ok(result.findings.some((finding) => finding.id === 'windows-executable'));
assert.ok(result.findings.some((finding) => finding.id === 'alias-dir'));
});

View File

@@ -9,6 +9,8 @@ const {
filterSummaryVms,
normalizeBaseUrl,
normalizeVCenterVm,
previewVCenterHosts,
settingsFromVCenterConnection,
vmMatchesFilter
} = await load('../services/vcenterService.js');
@@ -16,6 +18,27 @@ test('normalizeBaseUrl trims trailing slashes', () => {
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
});
test('standalone VMware host connections use the Web Services profile', async () => {
const settings = settingsFromVCenterConnection({
id: 'vc_host',
target_type: 'host',
base_url: 'https://esxi01.lab.local/sdk',
username: 'root',
password: 'secret',
enabled: 1,
api_mode: 'api'
});
assert.equal(settings.targetType, 'host');
assert.equal(settings.apiMode, 'web-services');
assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
await assert.rejects(
() => previewVCenterHosts({ connection: { ...settings, name: 'ESXi host' } }),
/Standalone VMware host connections/
);
});
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' },
@@ -32,6 +55,16 @@ test('normalizeVCenterVm maps guest identity and networking into a host candidat
assert.equal(candidate.guestFullName, 'Microsoft Windows Server 2022');
});
test('normalizeVCenterVm marks unknown guest operating systems as other', () => {
const candidate = normalizeVCenterVm(
{ vm: 'vm-unknown', name: 'MYSTERY-01', power_state: 'POWERED_ON' },
null,
[]
);
assert.equal(candidate.osFamily, 'other');
});
test('vmMatchesFilter supports hostname contains and IP equals matching', () => {
const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', address: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] };

16
server/utils/osFamily.js Normal file
View File

@@ -0,0 +1,16 @@
export const OS_FAMILIES = ['windows', 'linux', 'other'];
export function normalizeOsFamily(value, fallback = 'other') {
const text = String(value || '').trim().toLowerCase();
if (!text) return fallback;
if (['win', 'windows', 'windows server', 'microsoft windows'].includes(text) || text.includes('windows')) return 'windows';
if (['linux', 'unix'].includes(text) || /ubuntu|debian|rhel|red hat|centos|fedora|suse|oracle linux|rocky|alma|amazon linux|arch|kali/.test(text)) return 'linux';
return 'other';
}
export function osFamilyLabel(value) {
const normalized = normalizeOsFamily(value);
if (normalized === 'windows') return 'Windows';
if (normalized === 'linux') return 'Linux';
return 'Other';
}