Initial
This commit is contained in:
89
README.md
89
README.md
@@ -29,7 +29,7 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/
|
|||||||
- PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata.
|
- PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata.
|
||||||
- Intune Publishing Wizard for PSADT Win32 deployment plans, including package source, `.intunewin` tracking, command style, UI compatibility, requirements, detection rules, return-code policy, assignment rings, status, Graph app ID, and visibility.
|
- Intune Publishing Wizard for PSADT Win32 deployment plans, including package source, `.intunewin` tracking, command style, UI compatibility, requirements, detection rules, return-code policy, assignment rings, status, Graph app ID, and visibility.
|
||||||
- Microsoft Graph Intune tracking for PSADT deployments, with Graph environment configuration managed under Config / Intune plus mobile app linking, status sync, failure review, device/user status rows, and sync run history.
|
- Microsoft Graph Intune tracking for PSADT deployments, with Graph environment configuration managed under Config / Intune plus mobile app linking, status sync, failure review, device/user status rows, and sync run history.
|
||||||
- Host Library with searchable/sortable/paginated table, 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.
|
- Credential Vault as its own menu item with encrypted-at-rest secrets and modal add/edit flow.
|
||||||
- RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action.
|
- RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action.
|
||||||
- Aggregate job log viewer with search/filter/pagination.
|
- Aggregate job log viewer with search/filter/pagination.
|
||||||
@@ -506,12 +506,12 @@ Payload:
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET` | `/api/hosts` | User | List host library. |
|
| `GET` | `/api/hosts` | User | List host library. |
|
||||||
| `POST` | `/api/hosts` | User | Create host. |
|
| `POST` | `/api/hosts` | User | Create host. |
|
||||||
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved vCenter systems. |
|
| `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 vCenter systems. |
|
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
|
||||||
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted vCenter system record. |
|
| `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 vCenter system; omit password to keep the existing secret. |
|
| `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 vCenter system. |
|
| `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible VMware connection. |
|
||||||
| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Authenticate and list VM summaries to verify a vCenter system. |
|
| `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/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. |
|
| `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. |
|
| `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
|
`sourceConnectionId`, and `sourceRef` are hidden provenance fields. Manual
|
||||||
hosts default to `sourceType: "manual"`. vCenter imports set
|
hosts default to `sourceType: "manual"`. vCenter imports set
|
||||||
`sourceType: "vmware"`, `sourceConnectionId` to the selected vCenter system,
|
`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
|
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
|
||||||
without duplicating RunPlans. Manual groups store an explicit list of visible
|
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`,
|
"all"` or `matchMode: "any"`. Operators can use `contains`, `equals`,
|
||||||
`startsWith`, and `endsWith` operators. Rule matching is evaluated only against
|
`startsWith`, and `endsWith` operators. Rule matching is evaluated only against
|
||||||
hosts the group owner/creator can see through personal/shared/group visibility.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"name": "Production vCenter",
|
"name": "Production vCenter",
|
||||||
|
"targetType": "vcenter",
|
||||||
"baseUrl": "https://vcenter.contoso.local",
|
"baseUrl": "https://vcenter.contoso.local",
|
||||||
"username": "administrator@vsphere.local",
|
"username": "administrator@vsphere.local",
|
||||||
"password": "stored-encrypted-on-save",
|
"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
|
Create/update a saved standalone ESXi host connection:
|
||||||
by the Credential Vault. Existing `vcenter_*` settings and `VCENTER_*`
|
|
||||||
environment variables remain supported as the `Configured default` import
|
```json
|
||||||
connection for single-vCenter deployments or Docker-provided configuration.
|
{
|
||||||
|
"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.
|
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
|
treated like script failures. If the vCenter connection is missing or the
|
||||||
management API is temporarily unavailable, the runner logs the reason and
|
management API is temporarily unavailable, the runner logs the reason and
|
||||||
continues execution rather than blocking the whole job on a control-plane issue.
|
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
|
### 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. |
|
| `GET` | `/api/scripts` | User | List visible scripts. |
|
||||||
| `POST` | `/api/scripts` | User | Create script and initial version. |
|
| `POST` | `/api/scripts` | User | Create script and initial version. |
|
||||||
| `GET` | `/api/scripts/:id` | User | Read script. |
|
| `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. |
|
| `PUT` | `/api/scripts/:id` | User | Update script and create a version record. |
|
||||||
| `DELETE` | `/api/scripts/:id` | User | Delete script. |
|
| `DELETE` | `/api/scripts/:id` | User | Delete script. |
|
||||||
| `GET` | `/api/scripts/:id/versions` | User | List script versions. |
|
| `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. |
|
| `GET` | `/api/runplans` | User | List visible RunPlans. |
|
||||||
| `POST` | `/api/runplans` | User | Create RunPlan. |
|
| `POST` | `/api/runplans` | User | Create RunPlan. |
|
||||||
| `GET` | `/api/runplans/:id` | User | Read 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. |
|
| `PUT` | `/api/runplans/:id` | User | Update RunPlan. |
|
||||||
| `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. |
|
| `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. |
|
||||||
| `POST` | `/api/runplans/:id/execute` | User | Queue/execute a 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 '{}'
|
-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
|
### Settings And Logs
|
||||||
|
|
||||||
| Method | Route | Auth | Description |
|
| 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`.
|
- `ssh` hosts use `Invoke-Command -HostName`.
|
||||||
- Host credential resolution happens inside backend execution flows.
|
- Host credential resolution happens inside backend execution flows.
|
||||||
- POSHManager runtime variables are injected as PowerShell variables and `POSHM_*` environment variables.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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
|
## Security Notes
|
||||||
|
|
||||||
- SQLite access uses prepared statements through `node:sqlite`.
|
- SQLite access uses prepared statements through `node:sqlite`.
|
||||||
|
|||||||
@@ -331,6 +331,7 @@
|
|||||||
>
|
>
|
||||||
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
||||||
<template #cell-address="{ row }"><code>{{ row.address }}</code></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-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-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
|
||||||
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
|
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
|
||||||
@@ -462,7 +463,7 @@
|
|||||||
<form @submit.prevent="saveHost" class="form-grid">
|
<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>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>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><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>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>
|
<label class="full"><span>Tags</span><input v-model="hostForm.tagsText" placeholder="production, database, east" /></label>
|
||||||
@@ -1326,6 +1327,7 @@ const availableThemes = [
|
|||||||
const hostColumns = [
|
const hostColumns = [
|
||||||
{ key: 'name', label: 'Name' },
|
{ key: 'name', label: 'Name' },
|
||||||
{ key: 'address', label: 'Address' },
|
{ key: 'address', label: 'Address' },
|
||||||
|
{ key: 'osFamily', label: 'OS' },
|
||||||
{ key: 'transport', label: 'Transport' },
|
{ key: 'transport', label: 'Transport' },
|
||||||
{ key: 'sourceType', label: 'Source' },
|
{ key: 'sourceType', label: 'Source' },
|
||||||
{ key: 'credentialName', label: 'Credential' },
|
{ key: 'credentialName', label: 'Credential' },
|
||||||
@@ -1469,6 +1471,7 @@ const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host)
|
|||||||
host.name,
|
host.name,
|
||||||
host.address,
|
host.address,
|
||||||
host.fqdn,
|
host.fqdn,
|
||||||
|
host.osFamily,
|
||||||
host.transport,
|
host.transport,
|
||||||
host.sourceType,
|
host.sourceType,
|
||||||
host.sourceConnectionName,
|
host.sourceConnectionName,
|
||||||
@@ -1945,6 +1948,10 @@ async function executeScriptTestRun(payload) {
|
|||||||
scriptTestPollFailures = 0;
|
scriptTestPollFailures = 0;
|
||||||
stopScriptTestPolling();
|
stopScriptTestPolling();
|
||||||
try {
|
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);
|
const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload);
|
||||||
scriptTestJob.value = await api.get(`/api/jobs/${job.id}`).catch(() => job);
|
scriptTestJob.value = await api.get(`/api/jobs/${job.id}`).catch(() => job);
|
||||||
startScriptTestPolling(job.id);
|
startScriptTestPolling(job.id);
|
||||||
@@ -2471,6 +2478,12 @@ async function saveHost() {
|
|||||||
notify('Host saved');
|
notify('Host saved');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function osFamilyLabel(value) {
|
||||||
|
if (value === 'windows') return 'Windows';
|
||||||
|
if (value === 'linux') return 'Linux';
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
async function saveHostGroup(payload) {
|
async function saveHostGroup(payload) {
|
||||||
const body = { ...payload };
|
const body = { ...payload };
|
||||||
const groupId = body.id;
|
const groupId = body.id;
|
||||||
@@ -2669,6 +2682,7 @@ function openRunPlanModal(runplan = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeRunPlan(id) {
|
async function executeRunPlan(id) {
|
||||||
|
if (!(await confirmLinuxCompatibility(`/api/runplans/${id}/compatibility`))) return;
|
||||||
const job = await api.post(`/api/runplans/${id}/execute`, {});
|
const job = await api.post(`/api/runplans/${id}/execute`, {});
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
await openJob(job.id);
|
await openJob(job.id);
|
||||||
@@ -2676,6 +2690,20 @@ async function executeRunPlan(id) {
|
|||||||
notify('RunPlan queued');
|
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) {
|
async function openJob(id) {
|
||||||
selectedJob.value = await api.get(`/api/jobs/${id}`);
|
selectedJob.value = await api.get(`/api/jobs/${id}`);
|
||||||
view.value = 'logs';
|
view.value = 'logs';
|
||||||
|
|||||||
@@ -8,23 +8,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="section-actions">
|
<div class="section-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('sync-all')">
|
<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>
|
||||||
<button class="primary-action compact" type="button" @click="openModal()">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResourceTable
|
<ResourceTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="hostGroups"
|
:rows="pagedHostGroups"
|
||||||
:total="hostGroups.length"
|
: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"
|
item-label="host groups"
|
||||||
empty-text="No host groups yet."
|
empty-text="No host groups yet."
|
||||||
:page-count="1"
|
@update:search="groupSearch = $event"
|
||||||
:page="1"
|
@update:page="groupPage = $event"
|
||||||
:page-size="hostGroups.length || 8"
|
@update:pageSize="groupPageSize = $event"
|
||||||
|
@sort="toggleGroupSort"
|
||||||
>
|
>
|
||||||
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
||||||
<template #cell-mode="{ row }"><span :class="['status-pill', row.mode]">{{ row.mode }}</span></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-lastSyncedAt="{ row }">{{ row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleString() : '-' }}</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-actions">
|
<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="icon-button material-icon-button" type="button" title="View target collection" aria-label="View target collection" @click="openSummary(row)">
|
||||||
<button class="ghost-button compact" type="button" @click="openModal(row)">Edit</button>
|
<i class="mdi mdi-magnify" aria-hidden="true"></i>
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', row.id)">Delete</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ResourceTable>
|
</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
|
<BaseModal
|
||||||
:open="modalOpen"
|
:open="modalOpen"
|
||||||
:title="form.id ? 'Update host group' : 'Create host group'"
|
:title="form.id ? 'Update host group' : 'Create host group'"
|
||||||
@@ -70,12 +158,21 @@
|
|||||||
|
|
||||||
<div class="member-toolbar">
|
<div class="member-toolbar">
|
||||||
<label class="member-search">
|
<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..." />
|
<input v-model="memberSearch" placeholder="Search host, FQDN, address, tags..." />
|
||||||
<button v-if="memberSearch" type="button" aria-label="Clear host search" @click="memberSearch = ''">
|
<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>
|
</button>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
<span>Source</span>
|
<span>Source</span>
|
||||||
<select v-model="memberSource">
|
<select v-model="memberSource">
|
||||||
@@ -91,7 +188,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button :class="['ghost-button compact', { active: selectedOnly }]" type="button" @click="selectedOnly = !selectedOnly">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,7 +202,7 @@
|
|||||||
Clear page
|
Clear page
|
||||||
</button>
|
</button>
|
||||||
<button class="ghost-button compact danger-text" type="button" :disabled="!form.hostIds.length" @click="form.hostIds = []">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,10 +220,11 @@
|
|||||||
@change="$event.target.checked ? selectPage() : clearPage()"
|
@change="$event.target.checked ? selectPage() : clearPage()"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th><button type="button" @click="toggleMemberSort('name')">Host <component :is="memberSortIcon('name')" :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 <component :is="memberSortIcon('address')" :size="13" /></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('transport')">Transport <component :is="memberSortIcon('transport')" :size="13" /></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('sourceType')">Source <component :is="memberSortIcon('sourceType')" :size="13" /></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>
|
<th>Tags</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -140,12 +238,13 @@
|
|||||||
<small>{{ host.fqdn || 'No FQDN recorded' }}</small>
|
<small>{{ host.fqdn || 'No FQDN recorded' }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{{ host.address || '-' }}</code></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 neutral">{{ host.transport || '-' }}</span></td>
|
||||||
<td><span :class="['status-pill', host.sourceType === 'vmware' ? 'dynamic' : 'manual']">{{ sourceLabel(host.sourceType) }}</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>
|
<td><span class="member-tags">{{ tagSummary(host) }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!pagedHosts.length">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -163,10 +262,10 @@
|
|||||||
<span>Page {{ Math.min(memberPage, memberPageCount) }} of {{ memberPageCount }}</span>
|
<span>Page {{ Math.min(memberPage, memberPageCount) }} of {{ memberPageCount }}</span>
|
||||||
<div>
|
<div>
|
||||||
<button class="ghost-button compact" type="button" :disabled="memberPage <= 1" @click="memberPage -= 1">
|
<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>
|
||||||
<button class="ghost-button compact" type="button" :disabled="memberPage >= memberPageCount" @click="memberPage += 1">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,15 +292,15 @@
|
|||||||
</select>
|
</select>
|
||||||
<input v-model="rule.value" required placeholder="ENG" />
|
<input v-model="rule.value" required placeholder="ENG" />
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="removeRule(index)">
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<div class="form-actions modal-actions full">
|
<div class="form-actions modal-actions full">
|
||||||
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
@@ -210,7 +309,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
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 BaseModal from '../ui/BaseModal.vue';
|
||||||
import ResourceTable from '../ui/ResourceTable.vue';
|
import ResourceTable from '../ui/ResourceTable.vue';
|
||||||
|
|
||||||
@@ -222,7 +320,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
|
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
|
||||||
const modalOpen = ref(false);
|
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 memberSearch = ref('');
|
||||||
|
const memberOsFamily = ref('');
|
||||||
const memberSource = ref('');
|
const memberSource = ref('');
|
||||||
const memberTransport = ref('');
|
const memberTransport = ref('');
|
||||||
const selectedOnly = ref(false);
|
const selectedOnly = ref(false);
|
||||||
@@ -242,11 +348,37 @@ const columns = [
|
|||||||
|
|
||||||
const sourceOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.sourceType || 'manual')));
|
const sourceOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.sourceType || 'manual')));
|
||||||
const transportOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.transport).filter(Boolean)));
|
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 filteredHosts = computed(() => {
|
||||||
const term = memberSearch.value.trim().toLowerCase();
|
const term = memberSearch.value.trim().toLowerCase();
|
||||||
const selected = new Set(form.hostIds);
|
const selected = new Set(form.hostIds);
|
||||||
return props.hosts.filter((host) => {
|
return props.hosts.filter((host) => {
|
||||||
if (selectedOnly.value && !selected.has(host.id)) return false;
|
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 (memberSource.value && (host.sourceType || 'manual') !== memberSource.value) return false;
|
||||||
if (memberTransport.value && host.transport !== memberTransport.value) return false;
|
if (memberTransport.value && host.transport !== memberTransport.value) return false;
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
@@ -254,6 +386,7 @@ const filteredHosts = computed(() => {
|
|||||||
host.name,
|
host.name,
|
||||||
host.fqdn,
|
host.fqdn,
|
||||||
host.address,
|
host.address,
|
||||||
|
host.osFamily,
|
||||||
host.transport,
|
host.transport,
|
||||||
host.sourceType,
|
host.sourceType,
|
||||||
...(host.tags || [])
|
...(host.tags || [])
|
||||||
@@ -271,11 +404,30 @@ const pagedHosts = computed(() => {
|
|||||||
return filteredHosts.value.slice(start, start + memberPageSize.value);
|
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 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;
|
memberPage.value = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([groupSearch, groupPageSize], () => {
|
||||||
|
groupPage.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(groupPageCount, (pageCount) => {
|
||||||
|
if (groupPage.value > pageCount) groupPage.value = pageCount;
|
||||||
|
});
|
||||||
|
|
||||||
watch(memberPageCount, (pageCount) => {
|
watch(memberPageCount, (pageCount) => {
|
||||||
if (memberPage.value > pageCount) memberPage.value = pageCount;
|
if (memberPage.value > pageCount) memberPage.value = pageCount;
|
||||||
});
|
});
|
||||||
@@ -309,6 +461,11 @@ function openModal(group = null) {
|
|||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSummary(group) {
|
||||||
|
summaryGroup.value = group;
|
||||||
|
summaryOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function addRule() {
|
function addRule() {
|
||||||
form.rules.push({ field: 'name', operator: 'contains', value: '' });
|
form.rules.push({ field: 'name', operator: 'contains', value: '' });
|
||||||
}
|
}
|
||||||
@@ -328,10 +485,24 @@ function uniqueOptions(values) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sortableHostValue(host, key) {
|
function sortableHostValue(host, key) {
|
||||||
|
if (key === 'osFamily') return host.osFamily || 'other';
|
||||||
if (key === 'sourceType') return host.sourceType || 'manual';
|
if (key === 'sourceType') return host.sourceType || 'manual';
|
||||||
return String(host[key] || '');
|
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) {
|
function toggleMemberSort(key) {
|
||||||
if (memberSort.value === key) {
|
if (memberSort.value === key) {
|
||||||
memberDirection.value = memberDirection.value === 'asc' ? 'desc' : 'asc';
|
memberDirection.value = memberDirection.value === 'asc' ? 'desc' : 'asc';
|
||||||
@@ -341,9 +512,18 @@ function toggleMemberSort(key) {
|
|||||||
memberDirection.value = 'asc';
|
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) {
|
function memberSortIcon(key) {
|
||||||
if (memberSort.value !== key) return ChevronsUpDown;
|
if (memberSort.value !== key) return 'mdi mdi-swap-vertical';
|
||||||
return memberDirection.value === 'asc' ? ArrowUpAZ : ArrowDownAZ;
|
return memberDirection.value === 'asc' ? 'mdi mdi-sort-alphabetical-ascending' : 'mdi mdi-sort-alphabetical-descending';
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPage() {
|
function selectPage() {
|
||||||
@@ -365,6 +545,62 @@ function tagSummary(host) {
|
|||||||
return host.tags?.length ? host.tags.slice(0, 3).join(', ') : '-';
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileName(value) {
|
||||||
|
return String(value || 'host-group').trim().replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '') || 'host-group';
|
||||||
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
emit('save', {
|
emit('save', {
|
||||||
...form,
|
...form,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<span>vCenter system</span>
|
<span>vCenter system</span>
|
||||||
<select v-model="draft.connectionId">
|
<select v-model="draft.connectionId">
|
||||||
<option :value="null">Configured default</option>
|
<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 }}
|
{{ connection.name }} - {{ connection.baseUrl }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -55,14 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="wizard-actions">
|
<div class="wizard-actions">
|
||||||
<button class="ghost-button" type="button" :disabled="loading" @click="emitPreview">
|
<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>
|
</button>
|
||||||
<span v-if="status" class="vcenter-status">
|
<span v-if="status" class="vcenter-status">
|
||||||
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
|
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
|
||||||
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
|
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
|
||||||
</span>
|
</span>
|
||||||
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -133,13 +133,13 @@
|
|||||||
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
|
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
|
||||||
<span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</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')">
|
<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>
|
</button>
|
||||||
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
|
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-toolbar">
|
<div class="preview-toolbar">
|
||||||
<button class="ghost-button compact" type="button" :disabled="!previewRows.length" @click="toggleAll">
|
<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>
|
</button>
|
||||||
<span>{{ selectedIds.length }} of {{ previewRows.length }} selected</span>
|
<span>{{ selectedIds.length }} of {{ previewRows.length }} selected</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<div class="form-actions modal-actions">
|
<div class="form-actions modal-actions">
|
||||||
<button class="ghost-button" type="button" @click="$emit('close')">Cancel</button>
|
<button class="ghost-button" type="button" @click="$emit('close')">Cancel</button>
|
||||||
<button class="primary-action" type="submit" :disabled="loading || !selectedIds.length">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -189,7 +189,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { CheckSquare, DownloadCloud, FileSearch, Search } from '@lucide/vue';
|
|
||||||
import BaseModal from '../ui/BaseModal.vue';
|
import BaseModal from '../ui/BaseModal.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -220,10 +219,11 @@ const draft = reactive({
|
|||||||
});
|
});
|
||||||
const selectedIds = ref([]);
|
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 allSelected = computed(() => props.previewRows.length > 0 && selectedIds.value.length === props.previewRows.length);
|
||||||
const activeStatus = computed(() => {
|
const activeStatus = computed(() => {
|
||||||
if (draft.connectionId) {
|
if (draft.connectionId) {
|
||||||
const connection = props.connections.find((item) => item.id === draft.connectionId);
|
const connection = importConnections.value.find((item) => item.id === draft.connectionId);
|
||||||
return {
|
return {
|
||||||
name: connection?.name || 'Selected vCenter',
|
name: connection?.name || 'Selected vCenter',
|
||||||
configured: Boolean(connection?.baseUrl && connection?.username),
|
configured: Boolean(connection?.baseUrl && connection?.username),
|
||||||
@@ -233,6 +233,12 @@ const activeStatus = computed(() => {
|
|||||||
return props.status?.defaultConnection || props.status || {};
|
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) => {
|
watch(() => props.previewRows, (rows) => {
|
||||||
selectedIds.value = rows.map((row) => row.id);
|
selectedIds.value = rows.map((row) => row.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<select v-if="targetType === 'host'" v-model="hostId" required>
|
<select v-if="targetType === 'host'" v-model="hostId" required>
|
||||||
<option value="" disabled>Choose host</option>
|
<option value="" disabled>Choose host</option>
|
||||||
<option v-for="host in hosts" :key="host.id" :value="host.id">
|
<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>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-else v-model="hostGroupId" required>
|
<select v-else v-model="hostGroupId" required>
|
||||||
@@ -47,6 +47,11 @@
|
|||||||
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
|
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
|
||||||
</div>
|
</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">
|
<div class="form-actions modal-actions full">
|
||||||
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
|
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
|
||||||
<button class="primary-action" type="submit" :disabled="running || !targetSelected || !credentialId">
|
<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 selectedHostGroup = computed(() => props.hostGroups.find((group) => group.id === hostGroupId.value));
|
||||||
const selectedCredential = computed(() => props.credentials.find((credential) => credential.id === credentialId.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 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
|
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'));
|
: (selectedHostGroup.value ? `${selectedHostGroup.value.name} / ${selectedHostGroup.value.mode} / ${selectedHostGroup.value.memberCount || 0} host(s)` : 'No target selected'));
|
||||||
const selectedCredentialSummary = computed(() => selectedCredential.value
|
const selectedCredentialSummary = computed(() => selectedCredential.value
|
||||||
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
|
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
|
||||||
@@ -144,4 +155,10 @@ function execute() {
|
|||||||
credentialId: credentialId.value
|
credentialId: credentialId.value
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function osFamilyLabel(value = 'other') {
|
||||||
|
if (value === 'windows') return 'Windows';
|
||||||
|
if (value === 'linux') return 'Linux';
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,62 +3,89 @@
|
|||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">CONFIG / VMWARE</span>
|
<span class="eyebrow">CONFIG / VMWARE</span>
|
||||||
<h3>VMware vCenter</h3>
|
<h3>VMware connections</h3>
|
||||||
<p>Register one or more vCenter systems used for host imports and execution-time power checks.</p>
|
<p>Register vCenter inventory systems or standalone ESXi hosts for VMware-aware host management.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary-action compact" type="button" @click="openModal()">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="intune-config-grid">
|
<div class="intune-config-grid">
|
||||||
<section class="publisher-card">
|
<section class="publisher-card">
|
||||||
<strong>Inventory sources</strong>
|
<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">
|
<div class="config-stat-row">
|
||||||
<span><b>{{ connections.length }}</b><small>systems</small></span>
|
<span><b>{{ connections.length }}</b><small>systems</small></span>
|
||||||
<span><b>{{ enabledCount }}</b><small>enabled</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>
|
<span><b>{{ testedCount }}</b><small>tested</small></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="publisher-card graph-config-list">
|
<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">
|
<div v-for="connection in connections" :key="connection.id" class="check-row">
|
||||||
<b>{{ connection.name }}</b>
|
<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>
|
<span>
|
||||||
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
|
<button class="ghost-button compact" type="button" @click="openModal(connection)">
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
|
</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>
|
</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BaseModal
|
<BaseModal
|
||||||
:open="modalOpen"
|
:open="modalOpen"
|
||||||
:title="form.id ? 'Update vCenter system' : 'New vCenter system'"
|
:title="form.id ? 'Update VMware connection' : 'New VMware connection'"
|
||||||
eyebrow="CONFIG / VMWARE"
|
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
|
wide
|
||||||
@close="modalOpen = false"
|
@close="modalOpen = false"
|
||||||
>
|
>
|
||||||
<form class="form-grid" @submit.prevent="save">
|
<form class="form-grid" @submit.prevent="save">
|
||||||
<label><span>Name</span><input v-model="form.name" required placeholder="Production vCenter" /></label>
|
<label>
|
||||||
<label><span>Base URL</span><input v-model="form.baseUrl" required placeholder="https://vcenter.contoso.local" /></label>
|
<span>Connection type</span>
|
||||||
<label><span>Username</span><input v-model="form.username" required placeholder="administrator@vsphere.local" /></label>
|
<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>{{ 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>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><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 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.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 vCenter certificates.</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">
|
<div class="form-actions modal-actions full">
|
||||||
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
|
<button class="ghost-button" type="button" @click="modalOpen = false">
|
||||||
<button class="primary-action" type="submit"><Save :size="17" />Save vCenter</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
@@ -66,8 +93,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { CloudCog, Save } from '@lucide/vue';
|
|
||||||
import BaseModal from '../ui/BaseModal.vue';
|
import BaseModal from '../ui/BaseModal.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -80,12 +106,27 @@ const emit = defineEmits(['save', 'delete', 'test']);
|
|||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
const form = reactive(defaultForm());
|
const form = reactive(defaultForm());
|
||||||
const enabledCount = computed(() => props.connections.filter((connection) => connection.enabled).length);
|
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 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() {
|
function defaultForm() {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
|
targetType: 'vcenter',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -100,6 +141,8 @@ function defaultForm() {
|
|||||||
|
|
||||||
function openModal(connection = null) {
|
function openModal(connection = null) {
|
||||||
Object.assign(form, defaultForm(), connection || {});
|
Object.assign(form, defaultForm(), connection || {});
|
||||||
|
form.targetType = connection?.targetType || 'vcenter';
|
||||||
|
form.apiMode = form.targetType === 'host' ? 'web-services' : (connection?.apiMode || 'auto');
|
||||||
form.password = '';
|
form.password = '';
|
||||||
form.groupId = connection?.groupId || '';
|
form.groupId = connection?.groupId || '';
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
@@ -109,4 +152,8 @@ function save() {
|
|||||||
emit('save', { ...form, groupId: form.groupId || null });
|
emit('save', { ...form, groupId: form.groupId || null });
|
||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectionTypeLabel(connection) {
|
||||||
|
return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,6 +21,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<template v-if="kind === 'host'">
|
<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>
|
<label>
|
||||||
<span>Source</span>
|
<span>Source</span>
|
||||||
<select v-model="source">
|
<select v-model="source">
|
||||||
@@ -87,6 +96,7 @@
|
|||||||
<small>{{ secondaryText(item) }}</small>
|
<small>{{ secondaryText(item) }}</small>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'address'"><code>{{ item.address || '-' }}</code></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 === '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 === '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>
|
<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 emit = defineEmits(['update:modelValue']);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
|
const osFamily = ref('');
|
||||||
const source = ref('');
|
const source = ref('');
|
||||||
const transport = ref('');
|
const transport = ref('');
|
||||||
const selectedOnly = ref(false);
|
const selectedOnly = ref(false);
|
||||||
@@ -161,6 +172,7 @@ const columns = computed(() => props.kind === 'group'
|
|||||||
: [
|
: [
|
||||||
{ key: 'name', label: 'Host' },
|
{ key: 'name', label: 'Host' },
|
||||||
{ key: 'address', label: 'Address' },
|
{ key: 'address', label: 'Address' },
|
||||||
|
{ key: 'osFamily', label: 'OS' },
|
||||||
{ key: 'transport', label: 'Transport' },
|
{ key: 'transport', label: 'Transport' },
|
||||||
{ key: 'sourceType', label: 'Source' },
|
{ key: 'sourceType', label: 'Source' },
|
||||||
{ key: 'tags', label: 'Tags', sortable: false }
|
{ key: 'tags', label: 'Tags', sortable: false }
|
||||||
@@ -172,12 +184,13 @@ const filteredItems = computed(() => {
|
|||||||
const selected = new Set(selectedIds.value);
|
const selected = new Set(selectedIds.value);
|
||||||
return props.items.filter((item) => {
|
return props.items.filter((item) => {
|
||||||
if (selectedOnly.value && !selected.has(item.id)) return false;
|
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' && source.value && (item.sourceType || 'manual') !== source.value) return false;
|
||||||
if (props.kind === 'host' && transport.value && item.transport !== transport.value) return false;
|
if (props.kind === 'host' && transport.value && item.transport !== transport.value) return false;
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
const values = props.kind === 'group'
|
const values = props.kind === 'group'
|
||||||
? [item.name, item.mode, item.description, groupRuleSummary(item)]
|
? [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));
|
return values.filter(Boolean).some((value) => String(value).toLowerCase().includes(term));
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
const result = sortableValue(a, sort.value).localeCompare(sortableValue(b, sort.value), undefined, { numeric: true, sensitivity: 'base' });
|
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 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)));
|
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;
|
page.value = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +248,7 @@ function uniqueOptions(values) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sortableValue(item, key) {
|
function sortableValue(item, key) {
|
||||||
|
if (key === 'osFamily') return item.osFamily || 'other';
|
||||||
if (key === 'sourceType') return item.sourceType || 'manual';
|
if (key === 'sourceType') return item.sourceType || 'manual';
|
||||||
return String(item[key] || '');
|
return String(item[key] || '');
|
||||||
}
|
}
|
||||||
@@ -249,6 +263,12 @@ function sourceLabel(value = 'manual') {
|
|||||||
return value === 'vmware' ? 'VMware' : '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) {
|
function groupRuleSummary(item) {
|
||||||
if (item.mode === 'manual') return 'Manual membership';
|
if (item.mode === 'manual') return 'Manual membership';
|
||||||
return (item.rules || []).map((rule) => `${rule.field} ${rule.operator} ${rule.value}`).join(` ${item.matchMode || 'all'} `) || 'No rules';
|
return (item.rules || []).map((rule) => `${rule.field} ${rule.operator} ${rule.value}`).join(` ${item.matchMode || 'all'} `) || 'No rules';
|
||||||
|
|||||||
@@ -4024,6 +4024,28 @@ textarea:focus {
|
|||||||
color: var(--cyan);
|
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 {
|
.script-test-output {
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -4678,6 +4700,111 @@ body {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.vault-state {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4775,7 +4902,7 @@ body {
|
|||||||
|
|
||||||
.member-toolbar {
|
.member-toolbar {
|
||||||
display: grid;
|
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;
|
gap: 10px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
@@ -4803,7 +4930,8 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-search svg {
|
.member-search svg,
|
||||||
|
.member-search > i {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 13px;
|
left: 13px;
|
||||||
bottom: 13px;
|
bottom: 13px;
|
||||||
@@ -5000,6 +5128,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@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-browser-header,
|
||||||
.member-results-bar,
|
.member-results-bar,
|
||||||
.member-pagination {
|
.member-pagination {
|
||||||
@@ -5116,6 +5254,10 @@ body {
|
|||||||
.status-pill.warn,
|
.status-pill.warn,
|
||||||
.status-pill.running { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); }
|
.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 {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export async function previewVCenterImport(req, res) {
|
|||||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
|
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
|
||||||
try {
|
try {
|
||||||
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
|
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 }));
|
res.json(await previewVCenterHosts({ ...parsed.data, connection }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
|
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' });
|
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
|
||||||
try {
|
try {
|
||||||
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
|
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 preview = await previewVCenterHosts({ ...parsed.data, connection });
|
||||||
const selected = new Set(parsed.data.vmIds || []);
|
const selected = new Set(parsed.data.vmIds || []);
|
||||||
const candidates = selected.size
|
const candidates = selected.size
|
||||||
@@ -125,16 +125,16 @@ export function listVCenterConnectionRecords(req, res) {
|
|||||||
|
|
||||||
export function createVCenterConnectionRecord(req, res) {
|
export function createVCenterConnectionRecord(req, res) {
|
||||||
const parsed = vcenterConnectionSchema.safeParse(req.body);
|
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' });
|
||||||
if (!parsed.data.password) return res.status(400).json({ error: 'A vCenter password is required when creating a connection' });
|
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));
|
res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateVCenterConnectionRecord(req, res) {
|
export function updateVCenterConnectionRecord(req, res) {
|
||||||
const parsed = vcenterConnectionSchema.safeParse(req.body);
|
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);
|
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);
|
res.json(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ export function deleteVCenterConnectionRecord(req, res) {
|
|||||||
|
|
||||||
export async function testVCenterConnectionRecord(req, res) {
|
export async function testVCenterConnectionRecord(req, res) {
|
||||||
const connection = getVCenterConnection(req.params.connectionId, req.user.id, true);
|
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 {
|
try {
|
||||||
const result = await testVCenterConnection(connection);
|
const result = await testVCenterConnection(connection);
|
||||||
recordVCenterConnectionTest(req.params.connectionId, 'success', result.message);
|
recordVCenterConnectionTest(req.params.connectionId, 'success', result.message);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { folderSchema, scriptTestRunSchema } from '../forms/schemas.js';
|
import { folderSchema, scriptCompatibilitySchema, scriptTestRunSchema } from '../forms/schemas.js';
|
||||||
import {
|
import {
|
||||||
cloneScript,
|
cloneScript,
|
||||||
createFolder,
|
createFolder,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../models/libraryModel.js';
|
} from '../models/libraryModel.js';
|
||||||
import { camelJob } from '../models/mappers.js';
|
import { camelJob } from '../models/mappers.js';
|
||||||
import { executeScriptTest } from '../services/powershellRunner.js';
|
import { executeScriptTest } from '../services/powershellRunner.js';
|
||||||
|
import { analyzeScriptTargetCompatibility } from '../services/powershellCompatibilityService.js';
|
||||||
|
|
||||||
export function folders(req, res) {
|
export function folders(req, res) {
|
||||||
res.json(listFolders(req.user.id));
|
res.json(listFolders(req.user.id));
|
||||||
@@ -84,6 +85,14 @@ export function scriptUsageAction(req, res) {
|
|||||||
res.json(usage);
|
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) {
|
export function testRunScriptAction(req, res) {
|
||||||
const parsed = scriptTestRunSchema.safeParse(req.body);
|
const parsed = scriptTestRunSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' });
|
if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { camelJob } from '../models/mappers.js';
|
import { camelJob } from '../models/mappers.js';
|
||||||
import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js';
|
import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js';
|
||||||
import { executeRunPlan } from '../services/powershellRunner.js';
|
import { executeRunPlan } from '../services/powershellRunner.js';
|
||||||
|
import { analyzeRunPlanCompatibility } from '../services/powershellCompatibilityService.js';
|
||||||
|
|
||||||
export function index(req, res) {
|
export function index(req, res) {
|
||||||
res.json(listRunPlans(req.user.id));
|
res.json(listRunPlans(req.user.id));
|
||||||
@@ -37,3 +38,9 @@ export function execute(req, res) {
|
|||||||
res.status(400).json({ error: error.message });
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ const operationsArticles = [
|
|||||||
article('ops-hosts-runplans', 'Operations', 'Hosts, Credential Vault, RunPlans, And Jobs', 'Manage target systems and execute scripts safely.', [
|
article('ops-hosts-runplans', 'Operations', 'Hosts, Credential Vault, RunPlans, And Jobs', 'Manage target systems and execute scripts safely.', [
|
||||||
section('Hosts and credentials', [
|
section('Hosts and credentials', [
|
||||||
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
|
'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', [
|
section('RunPlans', [
|
||||||
'A RunPlan joins one script with one or more hosts. The backend creates a job and host-level log rows during execution.',
|
'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.', [
|
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/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/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', '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('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', 'List job history.'),
|
||||||
apiExample('Get', '/api/jobs/job_123', 'Read one job with host rows and logs.'),
|
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.')
|
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']),
|
], ['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.', [
|
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.'),
|
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')
|
section('Discovery examples', [], 'Get-Help Invoke-Command -Detailed\nGet-Command -Module Microsoft.PowerShell.Core\nGet-Command *PSSession*\nUpdate-Help -Force')
|
||||||
], ['powershell', 'cmdlets', 'reference']),
|
], ['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.', [
|
article('cmdlets-remoting', 'PowerShell', 'PowerShell Remoting Cmdlets', 'Useful cmdlets for WinRM/PSSession workflows used around POSHManager.', [
|
||||||
section('Session workflow', [
|
section('Session workflow', [
|
||||||
'Enable-PSRemoting configures a computer to receive remote commands. This must be done on targets outside POSHManager.',
|
'Enable-PSRemoting configures a computer to receive remote commands. This must be done on targets outside POSHManager.',
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function migrate() {
|
|||||||
secret_cipher TEXT NOT NULL,
|
secret_cipher TEXT NOT NULL,
|
||||||
secret_iv TEXT NOT NULL,
|
secret_iv TEXT NOT NULL,
|
||||||
secret_tag TEXT NOT NULL,
|
secret_tag TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL DEFAULT 'vcenter',
|
||||||
api_mode TEXT NOT NULL DEFAULT 'auto',
|
api_mode TEXT NOT NULL DEFAULT 'auto',
|
||||||
request_timeout_ms INTEGER NOT NULL DEFAULT 20000,
|
request_timeout_ms INTEGER NOT NULL DEFAULT 20000,
|
||||||
allow_untrusted_tls INTEGER NOT NULL DEFAULT 0,
|
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_type', "TEXT NOT NULL DEFAULT 'manual'");
|
||||||
ensureColumn('hosts', 'source_connection_id', 'TEXT');
|
ensureColumn('hosts', 'source_connection_id', 'TEXT');
|
||||||
ensureColumn('hosts', 'source_ref', '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
|
// 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.
|
// read-only connections can never be used to change a tenant by accident.
|
||||||
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
|
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
|
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
|
||||||
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -53,7 +54,7 @@ export const hostSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
fqdn: z.string().optional(),
|
fqdn: z.string().optional(),
|
||||||
address: z.string().min(1),
|
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'),
|
transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
|
||||||
port: z.number().int().nullable().optional(),
|
port: z.number().int().nullable().optional(),
|
||||||
credentialId: z.string().nullable().optional(),
|
credentialId: z.string().nullable().optional(),
|
||||||
@@ -68,10 +69,11 @@ export const hostSchema = z.object({
|
|||||||
|
|
||||||
export const vcenterConnectionSchema = z.object({
|
export const vcenterConnectionSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
|
targetType: z.enum(['vcenter', 'host']).optional().default('vcenter'),
|
||||||
baseUrl: z.string().url(),
|
baseUrl: z.string().url(),
|
||||||
username: z.string().min(1).max(200),
|
username: z.string().min(1).max(200),
|
||||||
password: z.string().optional().default(''),
|
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),
|
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional().default(20000),
|
||||||
allowUntrustedTls: z.boolean().optional().default(false),
|
allowUntrustedTls: z.boolean().optional().default(false),
|
||||||
enabled: z.boolean().optional().default(true),
|
enabled: z.boolean().optional().default(true),
|
||||||
@@ -364,6 +366,13 @@ const runPlanPayloadSchema = z.object({
|
|||||||
hostGroupIds: z.array(z.string()).optional()
|
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) {
|
export function normalizeScriptPayload(body) {
|
||||||
const parsed = scriptPayloadSchema.parse(body);
|
const parsed = scriptPayloadSchema.parse(body);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
||||||
import { camelHost } from './mappers.js';
|
import { camelHost } from './mappers.js';
|
||||||
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||||
|
|
||||||
export function listHosts(userId) {
|
export function listHosts(userId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
@@ -29,7 +30,7 @@ export function createHost(payload, userId) {
|
|||||||
payload.name,
|
payload.name,
|
||||||
payload.fqdn || '',
|
payload.fqdn || '',
|
||||||
payload.address,
|
payload.address,
|
||||||
payload.osFamily,
|
normalizeOsFamily(payload.osFamily, 'windows'),
|
||||||
payload.transport,
|
payload.transport,
|
||||||
payload.port || null,
|
payload.port || null,
|
||||||
payload.credentialId || null,
|
payload.credentialId || null,
|
||||||
@@ -80,7 +81,7 @@ export function updateHost(hostId, payload, userId) {
|
|||||||
payload.name || existing.name,
|
payload.name || existing.name,
|
||||||
payload.fqdn ?? existing.fqdn,
|
payload.fqdn ?? existing.fqdn,
|
||||||
payload.address || existing.address,
|
payload.address || existing.address,
|
||||||
payload.osFamily || existing.os_family,
|
normalizeOsFamily(payload.osFamily ?? existing.os_family, 'windows'),
|
||||||
payload.transport || existing.transport,
|
payload.transport || existing.transport,
|
||||||
payload.port ?? existing.port,
|
payload.port ?? existing.port,
|
||||||
payload.credentialId ?? existing.credential_id,
|
payload.credentialId ?? existing.credential_id,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseJson } from '../db.js';
|
import { parseJson } from '../db.js';
|
||||||
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||||
|
|
||||||
export function camelGroup(row) {
|
export function camelGroup(row) {
|
||||||
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at };
|
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,
|
name: row.name,
|
||||||
fqdn: row.fqdn,
|
fqdn: row.fqdn,
|
||||||
address: row.address,
|
address: row.address,
|
||||||
osFamily: row.os_family,
|
osFamily: normalizeOsFamily(row.os_family, 'other'),
|
||||||
transport: row.transport,
|
transport: row.transport,
|
||||||
port: row.port,
|
port: row.port,
|
||||||
credentialId: row.credential_id,
|
credentialId: row.credential_id,
|
||||||
@@ -53,6 +54,7 @@ export function camelVCenterConnection(row) {
|
|||||||
baseUrl: row.base_url,
|
baseUrl: row.base_url,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
hasPassword: Boolean(row.secret_cipher),
|
hasPassword: Boolean(row.secret_cipher),
|
||||||
|
targetType: row.target_type || 'vcenter',
|
||||||
apiMode: row.api_mode || 'auto',
|
apiMode: row.api_mode || 'auto',
|
||||||
requestTimeoutMs: row.request_timeout_ms || 20000,
|
requestTimeoutMs: row.request_timeout_ms || 20000,
|
||||||
allowUntrustedTls: Boolean(row.allow_untrusted_tls),
|
allowUntrustedTls: Boolean(row.allow_untrusted_tls),
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ function normalizeConnection(body, existing = {}) {
|
|||||||
...body,
|
...body,
|
||||||
password: body.password || ''
|
password: body.password || ''
|
||||||
});
|
});
|
||||||
|
const targetType = parsed.targetType || 'vcenter';
|
||||||
|
const apiMode = targetType === 'host'
|
||||||
|
? 'web-services'
|
||||||
|
: (parsed.apiMode === 'web-services' ? 'auto' : parsed.apiMode);
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
|
targetType,
|
||||||
|
apiMode,
|
||||||
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
|
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
|
||||||
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
||||||
};
|
};
|
||||||
@@ -52,10 +58,10 @@ export function createVCenterConnection(body, userId) {
|
|||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO vcenter_connections (
|
INSERT INTO vcenter_connections (
|
||||||
id, name, base_url, username, secret_cipher, secret_iv, secret_tag,
|
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
|
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
connectionId,
|
connectionId,
|
||||||
payload.name,
|
payload.name,
|
||||||
@@ -64,6 +70,7 @@ export function createVCenterConnection(body, userId) {
|
|||||||
encrypted.cipher,
|
encrypted.cipher,
|
||||||
encrypted.iv,
|
encrypted.iv,
|
||||||
encrypted.tag,
|
encrypted.tag,
|
||||||
|
payload.targetType,
|
||||||
payload.apiMode,
|
payload.apiMode,
|
||||||
payload.requestTimeoutMs,
|
payload.requestTimeoutMs,
|
||||||
payload.allowUntrustedTls ? 1 : 0,
|
payload.allowUntrustedTls ? 1 : 0,
|
||||||
@@ -85,6 +92,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
|
|||||||
name: existing.name,
|
name: existing.name,
|
||||||
baseUrl: existing.base_url,
|
baseUrl: existing.base_url,
|
||||||
username: existing.username,
|
username: existing.username,
|
||||||
|
targetType: existing.target_type || 'vcenter',
|
||||||
apiMode: existing.api_mode,
|
apiMode: existing.api_mode,
|
||||||
requestTimeoutMs: existing.request_timeout_ms,
|
requestTimeoutMs: existing.request_timeout_ms,
|
||||||
allowUntrustedTls: Boolean(existing.allow_untrusted_tls),
|
allowUntrustedTls: Boolean(existing.allow_untrusted_tls),
|
||||||
@@ -100,7 +108,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
|
|||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE vcenter_connections
|
UPDATE vcenter_connections
|
||||||
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
|
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 = ?
|
visibility = ?, group_id = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
@@ -110,6 +118,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
|
|||||||
encrypted.cipher,
|
encrypted.cipher,
|
||||||
encrypted.iv,
|
encrypted.iv,
|
||||||
encrypted.tag,
|
encrypted.tag,
|
||||||
|
payload.targetType,
|
||||||
payload.apiMode,
|
payload.apiMode,
|
||||||
payload.requestTimeoutMs,
|
payload.requestTimeoutMs,
|
||||||
payload.allowUntrustedTls ? 1 : 0,
|
payload.allowUntrustedTls ? 1 : 0,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deleteScriptAction,
|
deleteScriptAction,
|
||||||
folders,
|
folders,
|
||||||
getScriptAction,
|
getScriptAction,
|
||||||
|
scriptCompatibilityAction,
|
||||||
scriptUsageAction,
|
scriptUsageAction,
|
||||||
scripts,
|
scripts,
|
||||||
scriptVersions,
|
scriptVersions,
|
||||||
@@ -28,6 +29,7 @@ folderRoutes.delete('/:id', requireAuth, deleteFolderAction);
|
|||||||
scriptRoutes.get('/', requireAuth, scripts);
|
scriptRoutes.get('/', requireAuth, scripts);
|
||||||
scriptRoutes.post('/', requireAuth, createScriptAction);
|
scriptRoutes.post('/', requireAuth, createScriptAction);
|
||||||
scriptRoutes.get('/:id/usage', requireAuth, scriptUsageAction);
|
scriptRoutes.get('/:id/usage', requireAuth, scriptUsageAction);
|
||||||
|
scriptRoutes.post('/:id/compatibility', requireAuth, scriptCompatibilityAction);
|
||||||
scriptRoutes.post('/:id/clone', requireAuth, cloneScriptAction);
|
scriptRoutes.post('/:id/clone', requireAuth, cloneScriptAction);
|
||||||
scriptRoutes.post('/:id/test-run', requireAuth, testRunScriptAction);
|
scriptRoutes.post('/:id/test-run', requireAuth, testRunScriptAction);
|
||||||
scriptRoutes.get('/:id', requireAuth, getScriptAction);
|
scriptRoutes.get('/:id', requireAuth, getScriptAction);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
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';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
export const runPlanRoutes = Router();
|
export const runPlanRoutes = Router();
|
||||||
@@ -8,6 +8,7 @@ export const runPlanRoutes = Router();
|
|||||||
runPlanRoutes.get('/', requireAuth, index);
|
runPlanRoutes.get('/', requireAuth, index);
|
||||||
runPlanRoutes.post('/', requireAuth, create);
|
runPlanRoutes.post('/', requireAuth, create);
|
||||||
runPlanRoutes.get('/:id', requireAuth, show);
|
runPlanRoutes.get('/:id', requireAuth, show);
|
||||||
|
runPlanRoutes.get('/:id/compatibility', requireAuth, compatibility);
|
||||||
runPlanRoutes.put('/:id', requireAuth, update);
|
runPlanRoutes.put('/:id', requireAuth, update);
|
||||||
runPlanRoutes.delete('/:id', requireAuth, destroy);
|
runPlanRoutes.delete('/:id', requireAuth, destroy);
|
||||||
runPlanRoutes.post('/:id/execute', requireAuth, execute);
|
runPlanRoutes.post('/:id/execute', requireAuth, execute);
|
||||||
|
|||||||
334
server/services/powershellCompatibilityService.js
Normal file
334
server/services/powershellCompatibilityService.js
Normal 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
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'
|
|||||||
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
|
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
|
||||||
import { getVCenterVmPowerState } from './vcenterService.js';
|
import { getVCenterVmPowerState } from './vcenterService.js';
|
||||||
import { path } from '../utils/pathUtils.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
|
// 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
|
// 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) {
|
async function runJob(jobId, script, hosts, parallel, executionContext) {
|
||||||
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
|
// 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);
|
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
||||||
if (parallel) {
|
if (parallel) {
|
||||||
await Promise.all(hosts.map(runner));
|
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);
|
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) {
|
async function runHost(jobId, script, host, executionContext) {
|
||||||
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
|
// 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);
|
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', `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()) {
|
if (!scriptExecutionAllowed()) {
|
||||||
appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
|
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_NAME', value: host.name, valueType: 'string' },
|
||||||
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
|
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
|
||||||
{ name: 'POSHM_HOST_TRANSPORT', value: host.transport, 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_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
|
||||||
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
|
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer';
|
|||||||
import { Agent } from 'undici';
|
import { Agent } from 'undici';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||||
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||||
|
|
||||||
const WINDOWS_HINTS = ['windows', 'microsoft'];
|
const WINDOWS_HINTS = ['windows', 'microsoft'];
|
||||||
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
|
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
|
||||||
const MAX_TRACE_ENTRIES = 700;
|
const MAX_TRACE_ENTRIES = 700;
|
||||||
const MAX_TRACE_BODY_CHARS = 6000;
|
const MAX_TRACE_BODY_CHARS = 6000;
|
||||||
const VM_ENRICHMENT_CONCURRENCY = 8;
|
const VM_ENRICHMENT_CONCURRENCY = 8;
|
||||||
|
const WEB_SERVICES_PROFILE_LABEL = 'vSphere Web Services API';
|
||||||
const API_PROFILES = {
|
const API_PROFILES = {
|
||||||
api: {
|
api: {
|
||||||
mode: 'api',
|
mode: 'api',
|
||||||
@@ -45,6 +47,7 @@ export function getVCenterSettings() {
|
|||||||
const requestTimeoutMs = Number(getStringSetting('vcenter_request_timeout_ms', String(config.vcenter.requestTimeoutMs || 20000)));
|
const requestTimeoutMs = Number(getStringSetting('vcenter_request_timeout_ms', String(config.vcenter.requestTimeoutMs || 20000)));
|
||||||
return {
|
return {
|
||||||
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
|
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
|
||||||
|
targetType: 'vcenter',
|
||||||
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
|
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
|
||||||
username: getStringSetting('vcenter_username', config.vcenter.username),
|
username: getStringSetting('vcenter_username', config.vcenter.username),
|
||||||
password: getStringSetting('vcenter_password', config.vcenter.password),
|
password: getStringSetting('vcenter_password', config.vcenter.password),
|
||||||
@@ -57,12 +60,16 @@ export function getVCenterSettings() {
|
|||||||
export function settingsFromVCenterConnection(connection) {
|
export function settingsFromVCenterConnection(connection) {
|
||||||
if (!connection) return getVCenterSettings();
|
if (!connection) return getVCenterSettings();
|
||||||
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
|
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
|
||||||
|
const targetType = String(connection.target_type || connection.targetType || 'vcenter').toLowerCase() === 'host' ? 'host' : 'vcenter';
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(connection.enabled ?? true),
|
enabled: Boolean(connection.enabled ?? true),
|
||||||
|
targetType,
|
||||||
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
|
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
|
||||||
username: connection.username || '',
|
username: connection.username || '',
|
||||||
password: connection.password || '',
|
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),
|
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
|
||||||
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
|
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
|
||||||
};
|
};
|
||||||
@@ -77,6 +84,7 @@ export function vcenterStatus() {
|
|||||||
return {
|
return {
|
||||||
enabled: settings.enabled,
|
enabled: settings.enabled,
|
||||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||||
|
targetType: settings.targetType,
|
||||||
baseUrl: settings.baseUrl,
|
baseUrl: settings.baseUrl,
|
||||||
username: settings.username,
|
username: settings.username,
|
||||||
apiMode: settings.apiMode,
|
apiMode: settings.apiMode,
|
||||||
@@ -92,6 +100,7 @@ export function vcenterConnectionStatus(connection) {
|
|||||||
name: connection?.name || 'Configured default',
|
name: connection?.name || 'Configured default',
|
||||||
enabled: settings.enabled,
|
enabled: settings.enabled,
|
||||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||||
|
targetType: settings.targetType,
|
||||||
baseUrl: settings.baseUrl,
|
baseUrl: settings.baseUrl,
|
||||||
username: settings.username,
|
username: settings.username,
|
||||||
apiMode: settings.apiMode,
|
apiMode: settings.apiMode,
|
||||||
@@ -101,9 +110,10 @@ export function vcenterConnectionStatus(connection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertConfigured(settings) {
|
function assertConfigured(settings) {
|
||||||
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
|
const label = settings.targetType === 'host' ? 'VMware standalone host' : 'VMware vCenter';
|
||||||
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
|
if (!settings.enabled) throw new Error(`${label} integration is disabled in Configuration.`);
|
||||||
if (!settings.username || !settings.password) throw new Error('VMware vCenter credentials are missing. Set vcenter_username and vcenter_password 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) {
|
function dispatcherFor(settings) {
|
||||||
@@ -224,6 +234,115 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
|
|||||||
return payload;
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function unwrapList(payload) {
|
||||||
if (Array.isArray(payload)) return payload;
|
if (Array.isArray(payload)) return payload;
|
||||||
if (Array.isArray(payload?.value)) return payload.value;
|
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();
|
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 (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
|
||||||
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
|
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
|
||||||
return 'windows';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function localizedText(value) {
|
function localizedText(value) {
|
||||||
@@ -313,7 +432,7 @@ function normalizeVCenterSummaryVm(vm) {
|
|||||||
ipAddress: '',
|
ipAddress: '',
|
||||||
ipAddresses: [],
|
ipAddresses: [],
|
||||||
powerState: vm.power_state || vm.powerState || '',
|
powerState: vm.power_state || vm.powerState || '',
|
||||||
osFamily: 'windows',
|
osFamily: 'other',
|
||||||
guestFullName: '',
|
guestFullName: '',
|
||||||
toolsAvailable: false,
|
toolsAvailable: false,
|
||||||
source: 'vcenter'
|
source: 'vcenter'
|
||||||
@@ -380,7 +499,7 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
|
|||||||
name: candidate.name,
|
name: candidate.name,
|
||||||
fqdn: candidate.fqdn || '',
|
fqdn: candidate.fqdn || '',
|
||||||
address: candidate.address || candidate.ipAddress || candidate.name,
|
address: candidate.address || candidate.ipAddress || candidate.name,
|
||||||
osFamily: candidate.osFamily || 'windows',
|
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
|
||||||
transport: options.transport || 'winrm',
|
transport: options.transport || 'winrm',
|
||||||
port: options.port || null,
|
port: options.port || null,
|
||||||
credentialId: options.credentialId || null,
|
credentialId: options.credentialId || null,
|
||||||
@@ -407,6 +526,9 @@ export async function previewVCenterHosts(options = {}) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new VCenterTraceError(err.message, trace, 400);
|
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 { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
|
||||||
const allVms = unwrapList(vmPayload);
|
const allVms = unwrapList(vmPayload);
|
||||||
const summaryFilter = filterSummaryVms(allVms, options.filter);
|
const summaryFilter = filterSummaryVms(allVms, options.filter);
|
||||||
@@ -452,6 +574,7 @@ export async function testVCenterConnection(connection) {
|
|||||||
const trace = [];
|
const trace = [];
|
||||||
const settings = settingsFromVCenterConnection(connection);
|
const settings = settingsFromVCenterConnection(connection);
|
||||||
assertConfigured(settings);
|
assertConfigured(settings);
|
||||||
|
if (settings.targetType === 'host') return testStandaloneHostConnection(connection, settings, trace);
|
||||||
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
|
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
|
||||||
return {
|
return {
|
||||||
status: vcenterConnectionStatus(connection),
|
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) {
|
export async function getVCenterVmPowerState(connection, vmId, trace = null) {
|
||||||
if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' };
|
if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' };
|
||||||
const settings = settingsFromVCenterConnection(connection);
|
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);
|
assertConfigured(settings);
|
||||||
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|||||||
35
server/test/compatibility.test.mjs
Normal file
35
server/test/compatibility.test.mjs
Normal 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'));
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ const {
|
|||||||
filterSummaryVms,
|
filterSummaryVms,
|
||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
normalizeVCenterVm,
|
normalizeVCenterVm,
|
||||||
|
previewVCenterHosts,
|
||||||
|
settingsFromVCenterConnection,
|
||||||
vmMatchesFilter
|
vmMatchesFilter
|
||||||
} = await load('../services/vcenterService.js');
|
} = 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');
|
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', () => {
|
test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {
|
||||||
const candidate = normalizeVCenterVm(
|
const candidate = normalizeVCenterVm(
|
||||||
{ vm: 'vm-42', name: 'ENG-ENT-APP01', power_state: 'POWERED_ON' },
|
{ 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');
|
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', () => {
|
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'] };
|
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
16
server/utils/osFamily.js
Normal 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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user