This commit is contained in:
2026-06-25 18:02:16 -05:00
parent 402bf6782a
commit da16e5ff56
17 changed files with 1475 additions and 69 deletions

View File

@@ -32,6 +32,7 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/
- 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. - 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.
- Admin System Jobs console for background worker run-now actions, stuck execution cancellation, and audited job-management history.
- Aggregate job log viewer with search/filter/pagination. - Aggregate job log viewer with search/filter/pagination.
- System log tab for Winston request/application logs. - System log tab for Winston request/application logs.
- Users & Groups administration with modal create flows. - Users & Groups administration with modal create flows.
@@ -508,10 +509,10 @@ Payload:
| `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 VMware connection records. | | `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. | | `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted VMware connection record. Use `targetType: "vcenter"` for inventory or `targetType: "host"` for standalone ESXi Web Services testing. | | `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted VMware connection record. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible VMware connection; 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 VMware connection. | | `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible VMware connection. |
| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Verify a VMware connection. vCenter records authenticate and list VM summaries; standalone host records call the vSphere Web Services SOAP endpoint. | | `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Verify a VMware connection. vCenter records authenticate and list VM summaries; standalone ESXi records authenticate to `/sdk` and query VM inventory through the vSphere Web Services API. |
| `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. |
@@ -645,13 +646,23 @@ by the Credential Vault. `targetType: "vcenter"` records use the vSphere
Automation API or legacy REST API for inventory and VM power-state checks. Automation API or legacy REST API for inventory and VM power-state checks.
`targetType: "host"` records are standalone host connections that use the `targetType: "host"` records are standalone host connections that use the
vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi
connectivity but cannot perform vCenter VM inventory imports. Existing connectivity and enumerate VM inventory directly from the selected ESXi host in
the VMware import wizard. Existing
`vcenter_*` settings and `VCENTER_*` environment variables remain supported as `vcenter_*` settings and `VCENTER_*` environment variables remain supported as
the `Configured default` import connection for single-vCenter deployments or the `Configured default` import connection for single-vCenter deployments or
Docker-provided configuration. 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.
When the import wizard selects a standalone ESXi connection (`targetType:
"host"`), preview uses the vSphere Web Services API sequence
`RetrieveServiceContent`, `Login`, `CreateContainerView`, and
`RetrievePropertiesEx` against `/sdk` to enumerate `VirtualMachine` managed
objects. Filter fields work the same as vCenter imports. Imported hosts store
the selected ESXi connection plus the VM managed-object reference in
`sourceRef`, so script tests and RunPlans can run a direct ESXi power-state
check before spawning PowerShell.
For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. The combined `hostname` field searches both the VM inventory name and the VMware Tools guest `host_name`/FQDN after enrichment. VM display-name matches are checked first, then the rest of the inventory is still enriched for guest-only hostname matches. Networking/IP enrichment is only requested for candidates that match the filter. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes. For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. The combined `hostname` field searches both the VM inventory name and the VMware Tools guest `host_name`/FQDN after enrichment. VM display-name matches are checked first, then the rest of the inventory is still enriched for guest-only hostname matches. Networking/IP enrichment is only requested for candidates that match the filter. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes.
For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview. For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview.
@@ -703,14 +714,13 @@ hosts with the same name, address, or FQDN are skipped and returned in the
When a script test or RunPlan targets a host with `sourceType: "vmware"`, the When a script test or RunPlan targets a host with `sourceType: "vmware"`, the
runner checks the current vCenter power state for `sourceRef` through the stored runner checks the current vCenter power state for `sourceRef` through the stored
vCenter connection before spawning PowerShell. VMs that are not `POWERED_ON` VMware connection before spawning PowerShell. vCenter imports use the selected
vCenter API profile; standalone ESXi imports use the vSphere Web Services API
against `/sdk`. VMs that are not `POWERED_ON`
are logged and marked `skipped` for that host, so powered-off machines are not 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 VMware 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
@@ -1154,6 +1164,9 @@ with per-host log rows.
| `POST` | `/api/runplans/:id/execute` | User | Queue/execute a RunPlan. | | `POST` | `/api/runplans/:id/execute` | User | Queue/execute a RunPlan. |
| `GET` | `/api/jobs` | User | List jobs. | | `GET` | `/api/jobs` | User | List jobs. |
| `GET` | `/api/jobs/:id` | User | Read job with logs. | | `GET` | `/api/jobs/:id` | User | Read job with logs. |
| `GET` | `/api/system-jobs` | Admin | List running/queued execution jobs, controllable background workers, and recent job-management audit actions. |
| `POST` | `/api/system-jobs/:jobId/run` | Admin | Run a supported background worker now, such as dynamic Host Group sync, catalog auto-check, or Intune auto-promotion. |
| `POST` | `/api/system-jobs/:jobId/cancel` | Admin | Cancel a queued/running script execution job, signal active PowerShell child processes, and audit the action. |
RunPlan payload: RunPlan payload:
@@ -1205,6 +1218,27 @@ Windows-specific environment variables, and alias-heavy script style. RunPlan
execution and Script Test / Run also write the same warning summary into execution and Script Test / Run also write the same warning summary into
per-host job logs for Linux targets. per-host job logs for Linux targets.
Admin System Jobs console:
```bash
curl http://localhost:3000/api/system-jobs \
-H "Authorization: Bearer $TOKEN"
curl -X POST http://localhost:3000/api/system-jobs/worker%3Ahost-group-sync/run \
-H "Authorization: Bearer $TOKEN"
curl -X POST http://localhost:3000/api/system-jobs/job_123/cancel \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"reason":"Operator canceled stuck remoting session"}'
```
`/api/system-jobs` is admin-only. It combines execution jobs from RunPlans and
script tests with API-managed background workers. Canceling an execution job
marks queued/running host rows as canceled, writes a job log line, attempts to
terminate active PowerShell child processes, and records the management action
in the `system_job_actions` audit table.
### Settings And Logs ### Settings And Logs
| Method | Route | Auth | Description | | Method | Route | Auth | Description |

View File

@@ -597,6 +597,16 @@
</article> </article>
</section> </section>
<SystemJobsView
v-if="view === 'system-jobs'"
:jobs="systemJobs"
:actions="systemJobActions"
:loading="systemJobsLoading"
@refresh="refreshSystemJobs"
@run="runSystemJob"
@cancel="cancelSystemJob"
/>
<section v-if="view === 'admin'" class="admin-page"> <section v-if="view === 'admin'" class="admin-page">
<IntuneGraphConfig <IntuneGraphConfig
:graph-connections="graphConnections" :graph-connections="graphConnections"
@@ -984,6 +994,7 @@ import ErrorView from './views/ErrorView.vue';
import LoginView from './views/LoginView.vue'; import LoginView from './views/LoginView.vue';
import PsadtView from './views/PsadtView.vue'; import PsadtView from './views/PsadtView.vue';
import AssetsView from './views/AssetsView.vue'; import AssetsView from './views/AssetsView.vue';
import SystemJobsView from './views/SystemJobsView.vue';
self.MonacoEnvironment = { self.MonacoEnvironment = {
getWorker() { getWorker() {
@@ -1205,6 +1216,9 @@ const assetFolders = ref([]);
const assets = ref([]); const assets = ref([]);
const runplans = ref([]); const runplans = ref([]);
const jobs = ref([]); const jobs = ref([]);
const systemJobs = ref([]);
const systemJobActions = ref([]);
const systemJobsLoading = ref(false);
const variableCatalog = ref({ builtIns: [], psadt: [], custom: [] }); const variableCatalog = ref({ builtIns: [], psadt: [], custom: [] });
const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure: [], invokeParameters: [], functions: [], admxPolicies: [], launchCommands: [], snippets: [] }); const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure: [], invokeParameters: [], functions: [], admxPolicies: [], launchCommands: [], snippets: [] });
const psadtProfiles = ref([]); const psadtProfiles = ref([]);
@@ -1299,6 +1313,7 @@ const nav = [
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound }, { id: 'credentials', label: 'Credential Vault', icon: KeyRound },
{ id: 'runplans', label: 'RunPlans', icon: Rocket }, { id: 'runplans', label: 'RunPlans', icon: Rocket },
{ id: 'logs', label: 'Logs', icon: Activity }, { id: 'logs', label: 'Logs', icon: Activity },
{ id: 'system-jobs', label: 'System Jobs', icon: Database },
{ id: 'users', label: 'Users & Groups', icon: UsersRound }, { id: 'users', label: 'Users & Groups', icon: UsersRound },
{ id: 'admin', label: 'Config', icon: Settings } { id: 'admin', label: 'Config', icon: Settings }
]; ];
@@ -1745,6 +1760,11 @@ async function refreshAll() {
vcenterConnections.value = vcenterRows; vcenterConnections.value = vcenterRows;
runplans.value = runplanRows; runplans.value = runplanRows;
jobs.value = jobRows; jobs.value = jobRows;
if (user.value?.role === 'admin') await refreshSystemJobs(true);
else {
systemJobs.value = [];
systemJobActions.value = [];
}
if (selectedScript.value) { if (selectedScript.value) {
const refreshedSelection = scriptRows.find((script) => script.id === selectedScript.value.id); const refreshedSelection = scriptRows.find((script) => script.id === selectedScript.value.id);
if (refreshedSelection) selectScript(refreshedSelection); if (refreshedSelection) selectScript(refreshedSelection);
@@ -2532,7 +2552,7 @@ async function previewVCenterImport(payload) {
vcenterImportError.value = ''; vcenterImportError.value = '';
vcenterTraceEntries.value = [{ vcenterTraceEntries.value = [{
info: true, info: true,
message: 'vCenter preview request is running.', message: 'VMware host preview request is running.',
details: { details: {
filter: payload.filter, filter: payload.filter,
limit: payload.limit, limit: payload.limit,
@@ -2550,7 +2570,7 @@ async function previewVCenterImport(payload) {
details: { details: {
route: '/api/hosts/import/vcenter/preview', route: '/api/hosts/import/vcenter/preview',
elapsedMs: 8000, elapsedMs: 8000,
note: 'If this remains here, the backend is still waiting on vCenter DNS, TCP, TLS, authentication, or VM inventory response.' note: 'If this remains here, the backend is still waiting on VMware DNS, TCP, TLS, authentication, or inventory response.'
} }
} }
]; ];
@@ -2564,7 +2584,7 @@ async function previewVCenterImport(payload) {
details: { details: {
route: '/api/hosts/import/vcenter/preview', route: '/api/hosts/import/vcenter/preview',
elapsedMs: 18000, elapsedMs: 18000,
note: 'The backend vCenter request timeout should return a traceable timeout shortly unless the API process is blocked before dispatch.' note: 'The backend VMware request timeout should return a traceable timeout shortly unless the API process is blocked before dispatch.'
} }
} }
]; ];
@@ -2581,11 +2601,14 @@ async function previewVCenterImport(payload) {
}]; }];
vcenterPreviewRows.value = result.candidates || []; vcenterPreviewRows.value = result.candidates || [];
vcenterTraceOpen.value = true; vcenterTraceOpen.value = true;
notify(`Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`); const standalone = result.diagnostics?.standaloneHost;
notify(standalone
? `Prepared ${result.count || 0} standalone ESXi host candidate(s)`
: `Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`);
} catch (err) { } catch (err) {
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{ vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
info: true, info: true,
message: 'Preview failed before a vCenter HTTP trace could be captured.', message: 'Preview failed before a VMware API trace could be captured.',
details: { error: err.message, status: err.status || null } details: { error: err.message, status: err.status || null }
}]; }];
vcenterTraceOpen.value = true; vcenterTraceOpen.value = true;
@@ -2602,7 +2625,7 @@ async function importVCenterHosts(payload) {
vcenterImportError.value = ''; vcenterImportError.value = '';
vcenterTraceEntries.value = [{ vcenterTraceEntries.value = [{
info: true, info: true,
message: 'vCenter import request is running.', message: 'VMware host import request is running.',
details: { vmIds: payload.vmIds, startedAt: new Date().toISOString() } details: { vmIds: payload.vmIds, startedAt: new Date().toISOString() }
}]; }];
vcenterTraceOpen.value = true; vcenterTraceOpen.value = true;
@@ -2619,7 +2642,7 @@ async function importVCenterHosts(payload) {
} catch (err) { } catch (err) {
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{ vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
info: true, info: true,
message: 'Import failed before a vCenter HTTP trace could be captured.', message: 'Import failed before a VMware API trace could be captured.',
details: { error: err.message, status: err.status || null } details: { error: err.message, status: err.status || null }
}]; }];
vcenterTraceOpen.value = true; vcenterTraceOpen.value = true;
@@ -2717,6 +2740,49 @@ async function loadSystemLogs() {
systemLogs.value = await api.get('/api/logs/system?limit=300'); systemLogs.value = await api.get('/api/logs/system?limit=300');
} }
async function refreshSystemJobs(quiet = false) {
if (user.value?.role !== 'admin') return;
systemJobsLoading.value = true;
try {
const result = await api.get('/api/system-jobs');
systemJobs.value = result.jobs || [];
systemJobActions.value = result.actions || [];
} catch (err) {
if (!quiet) notify(err.message, 'error');
} finally {
systemJobsLoading.value = false;
}
}
async function runSystemJob(jobId) {
systemJobsLoading.value = true;
try {
const result = await api.post(`/api/system-jobs/${encodeURIComponent(jobId)}/run`, {});
notify(result.action?.message || 'System job completed');
await refreshSystemJobs(true);
} catch (err) {
notify(err.message, 'error');
await refreshSystemJobs(true);
} finally {
systemJobsLoading.value = false;
}
}
async function cancelSystemJob({ id, reason }) {
systemJobsLoading.value = true;
try {
const result = await api.post(`/api/system-jobs/${encodeURIComponent(id)}/cancel`, { reason });
notify(result.action?.message || 'Cancel requested');
jobs.value = await api.get('/api/jobs');
await refreshSystemJobs(true);
} catch (err) {
notify(err.message, 'error');
await refreshSystemJobs(true);
} finally {
systemJobsLoading.value = false;
}
}
async function saveSettings() { async function saveSettings() {
const payload = {}; const payload = {};
Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; }); Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; });

View File

@@ -1,9 +1,9 @@
<template> <template>
<BaseModal <BaseModal
:open="open" :open="open"
title="Import hosts from vCenter" title="Import VMware hosts"
eyebrow="VMWARE VCENTER" eyebrow="VMWARE"
description="Search vCenter virtual machines, preview discovered identity data, and create managed hosts with one shared vault credential." description="Import virtual machines from vCenter or standalone ESXi inventory into the Host Library."
wide wide
@close="$emit('close')" @close="$emit('close')"
> >
@@ -12,17 +12,17 @@
<div class="section-heading"> <div class="section-heading">
<span>1</span> <span>1</span>
<div> <div>
<h4>Search filter</h4> <h4>{{ isStandaloneHost ? 'ESXi VM inventory' : 'Search filter' }}</h4>
<p>Use vCenter VM names, guest hostnames, FQDNs, or IPs to narrow the import set before writing hosts.</p> <p>{{ isStandaloneHost ? 'Use the vSphere Web Services API on the selected ESXi host to enumerate guest VMs before writing hosts.' : 'Use vCenter VM names, guest hostnames, FQDNs, or IPs to narrow the import set before writing hosts.' }}</p>
</div> </div>
</div> </div>
<div class="vcenter-filter-grid"> <div class="vcenter-filter-grid">
<label class="filter-value"> <label class="filter-value">
<span>vCenter system</span> <span>VMware connection</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 importConnections" :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.targetType === 'host' ? 'Standalone ESXi' : 'vCenter' }} - {{ connection.baseUrl }}
</option> </option>
</select> </select>
</label> </label>
@@ -52,14 +52,19 @@
<span>Preview limit</span> <span>Preview limit</span>
<input v-model.number="draft.limit" type="number" min="1" max="500" /> <input v-model.number="draft.limit" type="number" min="1" max="500" />
</label> </label>
<div v-if="isStandaloneHost" class="standalone-host-callout">
<strong>{{ selectedConnection?.name || 'Selected ESXi host' }}</strong>
<span>{{ selectedConnection?.baseUrl || 'Choose a standalone ESXi connection above.' }}</span>
<small>Standalone ESXi inventory is queried through the /sdk Web Services endpoint.</small>
</div>
</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">
<i class="mdi mdi-magnify" aria-hidden="true"></i>Preview matches <i class="mdi mdi-magnify" aria-hidden="true"></i>{{ isStandaloneHost ? 'Preview VMs' : '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 || activeStatus.typeLabel || 'VMware'} at ${activeStatus.baseUrl}` : 'VMware 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')">
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>View API trace <i class="mdi mdi-file-search-outline" aria-hidden="true"></i>View API trace
@@ -123,11 +128,11 @@
<span>3</span> <span>3</span>
<div> <div>
<h4>Preview and select</h4> <h4>Preview and select</h4>
<p>Guest FQDN/IP data depends on VMware Tools. Missing values are imported with the VM name as the address fallback.</p> <p>{{ isStandaloneHost ? 'Guest FQDN/IP data comes from VMware Tools through ESXi. Missing values are imported with the VM name as the address fallback.' : 'Guest FQDN/IP data depends on VMware Tools. Missing values are imported with the VM name as the address fallback.' }}</p>
</div> </div>
</div> </div>
<div v-if="error" class="inline-error">{{ error }}</div> <div v-if="error" class="inline-error">{{ error }}</div>
<div v-if="diagnostics" class="vcenter-diagnostics"> <div v-if="diagnostics && !diagnostics.standaloneHost" class="vcenter-diagnostics">
<strong>Preview diagnostics</strong> <strong>Preview diagnostics</strong>
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span> <span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span>
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span> <span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
@@ -137,6 +142,15 @@
</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 v-else-if="diagnostics?.standaloneHost" class="vcenter-diagnostics">
<strong>Standalone ESXi preview</strong>
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by ESXi Web Services inventory</span>
<span>{{ diagnostics.summaryMatched }} VM(s) matched the current filter</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')">
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>Trace
</button>
</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">
<i class="mdi mdi-checkbox-marked-outline" aria-hidden="true"></i>{{ allSelected ? 'Clear selection' : 'Select all' }} <i class="mdi mdi-checkbox-marked-outline" aria-hidden="true"></i>{{ allSelected ? 'Clear selection' : 'Select all' }}
@@ -219,13 +233,16 @@ const draft = reactive({
}); });
const selectedIds = ref([]); const selectedIds = ref([]);
const importConnections = computed(() => props.connections.filter((connection) => connection.targetType !== 'host')); const importConnections = computed(() => props.connections);
const selectedConnection = computed(() => props.connections.find((item) => item.id === draft.connectionId) || null);
const isStandaloneHost = computed(() => selectedConnection.value?.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 = importConnections.value.find((item) => item.id === draft.connectionId); const connection = selectedConnection.value;
return { return {
name: connection?.name || 'Selected vCenter', name: connection?.name || 'Selected VMware connection',
typeLabel: connection?.targetType === 'host' ? 'ESXi host' : 'vCenter',
configured: Boolean(connection?.baseUrl && connection?.username), configured: Boolean(connection?.baseUrl && connection?.username),
baseUrl: connection?.baseUrl || '' baseUrl: connection?.baseUrl || ''
}; };

View File

@@ -4645,6 +4645,95 @@ body {
gap: 14px; gap: 14px;
} }
.system-jobs-page {
align-content: start;
}
.system-job-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.system-job-metrics article {
min-height: 96px;
padding: 16px;
border: 1px solid rgba(156, 199, 232, .14);
border-radius: 18px;
display: grid;
align-content: center;
gap: 5px;
background:
radial-gradient(circle at 12% 0, rgba(88, 221, 255, .13), transparent 42%),
linear-gradient(145deg, rgba(15, 31, 58, .76), rgba(5, 12, 28, .72));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .045);
}
.system-job-metrics strong {
color: #fff;
font: 800 28px/1 var(--display-font);
}
.system-job-metrics small {
color: rgba(200, 218, 238, .68);
font-size: 12px;
}
.system-job-filter {
min-width: 130px;
}
.system-jobs-table .resource-toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(132px, 160px)) minmax(260px, 1fr) 92px auto;
align-items: end;
gap: 10px;
}
.system-jobs-table .system-job-filter,
.system-jobs-table .table-density-select {
width: 100%;
}
.system-jobs-table .system-job-filter select,
.system-jobs-table .table-density-select select {
width: 100%;
}
.system-jobs-table .search-control {
width: min(100%, 360px);
justify-self: end;
}
.system-jobs-table .toolbar-count {
justify-self: end;
white-space: nowrap;
}
.system-jobs-table .resource-table td:first-child strong,
.system-jobs-table .resource-table td:first-child small {
display: block;
}
.system-jobs-table .resource-table td:first-child small {
margin-top: 3px;
color: rgba(202, 216, 238, .58);
font-size: 11px;
line-height: 1.35;
}
.system-job-audit {
overflow: hidden;
}
.system-job-audit .section-title {
padding: 16px 16px 14px;
}
.system-job-audit .data-table-wrap {
margin: 0 16px 16px;
}
.resource-table-panel { .resource-table-panel {
padding: 15px; padding: 15px;
border-radius: 20px; border-radius: 20px;
@@ -5128,6 +5217,23 @@ body {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.system-job-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.system-jobs-table .resource-toolbar {
grid-template-columns: 1fr;
}
.system-jobs-table .search-control {
width: 100%;
justify-self: stretch;
}
.system-jobs-table .toolbar-count {
justify-self: start;
}
.summary-metrics { .summary-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -5250,9 +5356,18 @@ body {
.status-pill.stdout, .status-pill.stdout,
.status-pill.info, .status-pill.info,
.status-pill.completed, .status-pill.completed,
.status-pill.success,
.status-pill.local { color: var(--mint); background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .2); } .status-pill.local { color: var(--mint); background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .2); }
.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,
.status-pill.canceling,
.status-pill.started { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); }
.status-pill.canceled,
.status-pill.skipped { color: rgba(226, 237, 250, .78); background: rgba(156, 199, 232, .08); border-color: rgba(156, 199, 232, .18); }
.status-pill.idle,
.status-pill.background,
.status-pill.execution,
.status-pill.neutral { color: #8be0ff; background: rgba(88, 221, 255, .09); border-color: rgba(88, 221, 255, .2); }
.status-pill.windows { color: #8be0ff; background: rgba(88, 221, 255, .1); border-color: rgba(88, 221, 255, .24); } .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.linux { color: #64e7bd; background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .24); }
@@ -7499,6 +7614,43 @@ input:read-only {
grid-column: span 1; grid-column: span 1;
} }
.standalone-host-callout {
min-height: 58px;
padding: 12px 14px;
border: 1px solid rgba(88, 221, 255, .18);
border-radius: 14px;
display: grid;
align-content: center;
gap: 4px;
background:
radial-gradient(circle at 10% 0, rgba(88, 221, 255, .16), transparent 42%),
linear-gradient(145deg, rgba(9, 24, 45, .82), rgba(4, 11, 25, .72));
}
.standalone-host-callout strong,
.standalone-host-callout span,
.standalone-host-callout small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.standalone-host-callout strong {
color: #fff;
font-size: 12px;
}
.standalone-host-callout span {
color: rgba(218, 236, 255, .78);
font: 700 11px "SFMono-Regular", Consolas, monospace;
}
.standalone-host-callout small {
color: rgba(180, 199, 222, .64);
font-size: 10px;
}
.wizard-actions, .wizard-actions,
.preview-toolbar { .preview-toolbar {
display: flex; display: flex;

View File

@@ -0,0 +1,241 @@
<template>
<section class="resource-page system-jobs-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">OPERATIONS CONTROL</span>
<h2>System Jobs</h2>
<p>Monitor background workers and cancel stuck script execution jobs from one audited control surface.</p>
</div>
<div class="page-actions">
<button class="ghost-button compact" type="button" @click="$emit('refresh')">
<i class="mdi mdi-refresh" aria-hidden="true"></i>Refresh
</button>
</div>
</div>
<div class="system-job-metrics">
<article>
<span class="eyebrow">RUNNING</span>
<strong>{{ runningCount }}</strong>
<small>active job(s)</small>
</article>
<article>
<span class="eyebrow">CANCELLABLE</span>
<strong>{{ cancellableCount }}</strong>
<small>script execution job(s)</small>
</article>
<article>
<span class="eyebrow">BACKGROUND</span>
<strong>{{ backgroundCount }}</strong>
<small>managed worker(s)</small>
</article>
<article>
<span class="eyebrow">AUDIT</span>
<strong>{{ actions.length }}</strong>
<small>recent action(s)</small>
</article>
</div>
<ResourceTable
class="system-jobs-table"
:columns="jobColumns"
:rows="pagedJobs"
:total="filteredJobs.length"
:search="filters.search"
:page="filters.page"
:page-count="pageCount"
:page-size="filters.pageSize"
:sort="filters.sort"
:direction="filters.direction"
search-placeholder="Search job, worker, category, status..."
item-label="jobs"
empty-text="No system jobs match the current filters."
@update:search="filters.search = $event"
@update:page="filters.page = $event"
@update:pageSize="filters.pageSize = $event"
@sort="setSort"
>
<template #toolbar-leading>
<label class="table-density-select system-job-filter">
<span>Type</span>
<select v-model="filters.type">
<option value="">All</option>
<option value="execution">Execution</option>
<option value="background">Background</option>
</select>
</label>
<label class="table-density-select system-job-filter">
<span>Status</span>
<select v-model="filters.status">
<option value="">Any</option>
<option value="running">Running</option>
<option value="queued">Queued</option>
<option value="idle">Idle</option>
<option value="failed">Failed</option>
<option value="canceled">Canceled</option>
</select>
</label>
</template>
<template #cell-name="{ row }">
<strong>{{ row.name }}</strong>
<small>{{ row.description }}</small>
</template>
<template #cell-type="{ row }"><span :class="['status-pill', row.type]">{{ row.type }}</span></template>
<template #cell-status="{ row }"><span :class="['status-pill', row.status]">{{ row.status }}</span></template>
<template #cell-startedAt="{ row }">{{ shortDate(row.startedAt || row.createdAt || row.metadata?.lastRunAt) || '-' }}</template>
<template #cell-hosts="{ row }">
<span v-if="row.type === 'execution'">{{ row.runningHosts || 0 }} running / {{ row.hostCount || 0 }} total</span>
<span v-else>{{ row.metadata?.lastSummary ? summaryText(row.metadata.lastSummary) : 'worker task' }}</span>
</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button v-if="row.canRunNow" class="ghost-button compact" type="button" :disabled="loading" @click="$emit('run', row.id)">
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>Run now
</button>
<button v-if="row.canCancel" class="ghost-button compact danger-text" type="button" :disabled="loading" @click="openCancel(row)">
<i class="mdi mdi-cancel" aria-hidden="true"></i>Cancel
</button>
<span v-if="!row.canRunNow && !row.canCancel" class="status-pill neutral">view only</span>
</div>
</template>
</ResourceTable>
<article class="glass-panel system-job-audit">
<div class="section-title">
<div>
<span class="eyebrow">AUDIT TRAIL</span>
<h3>Job management actions</h3>
<p>Every run-now and cancel operation is recorded with actor, status, message, and metadata.</p>
</div>
</div>
<div class="data-table-wrap">
<table class="data-table resource-table">
<thead>
<tr><th>Time</th><th>Action</th><th>Target</th><th>Status</th><th>Actor</th><th>Message</th></tr>
</thead>
<tbody>
<tr v-for="action in actions.slice(0, 40)" :key="action.id">
<td>{{ shortDate(action.createdAt) }}</td>
<td><strong>{{ action.action }}</strong></td>
<td><code>{{ action.targetId }}</code></td>
<td><span :class="['status-pill', action.status]">{{ action.status }}</span></td>
<td>{{ action.actorName || action.actorUserId || '-' }}</td>
<td>{{ action.message || '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="!actions.length" class="widget-empty">No job management actions have been recorded yet.</div>
</div>
</article>
<BaseModal
:open="Boolean(cancelTarget)"
title="Cancel execution job"
eyebrow="JOB MANAGEMENT"
:description="cancelTarget ? `Cancel ${cancelTarget.name}. Running PowerShell processes will be signaled and queued hosts will be marked canceled.` : ''"
@close="cancelTarget = null"
>
<form class="form-grid" @submit.prevent="submitCancel">
<label class="full">
<span>Reason</span>
<textarea v-model="cancelReason" rows="3" placeholder="Stuck remoting session, bad target selection, operator requested stop..."></textarea>
</label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="cancelTarget = null">
<i class="mdi mdi-close" aria-hidden="true"></i>Close
</button>
<button class="primary-action danger-action" type="submit">
<i class="mdi mdi-cancel" aria-hidden="true"></i>Cancel job
</button>
</div>
</form>
</BaseModal>
</section>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import BaseModal from '../components/ui/BaseModal.vue';
import ResourceTable from '../components/ui/ResourceTable.vue';
const props = defineProps({
jobs: { type: Array, default: () => [] },
actions: { type: Array, default: () => [] },
loading: Boolean
});
const emit = defineEmits(['refresh', 'run', 'cancel']);
const filters = reactive({ search: '', type: '', status: '', page: 1, pageSize: 8, sort: 'status', direction: 'asc' });
const cancelTarget = ref(null);
const cancelReason = ref('');
const jobColumns = [
{ key: 'name', label: 'Job / Worker' },
{ key: 'type', label: 'Type' },
{ key: 'category', label: 'Category' },
{ key: 'status', label: 'Status' },
{ key: 'startedAt', label: 'Started / Last Run' },
{ key: 'hosts', label: 'Scope', sortable: false },
{ key: 'actions', label: 'Actions', sortable: false }
];
const filteredJobs = computed(() => {
const term = filters.search.trim().toLowerCase();
return props.jobs.filter((job) => {
if (filters.type && job.type !== filters.type) return false;
if (filters.status && job.status !== filters.status) return false;
if (!term) return true;
return [job.name, job.description, job.category, job.status, job.id]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(term));
}).sort((a, b) => {
const left = sortableValue(a, filters.sort);
const right = sortableValue(b, filters.sort);
const result = left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
return filters.direction === 'asc' ? result : -result;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredJobs.value.length / filters.pageSize)));
const pagedJobs = computed(() => {
const start = (Math.min(filters.page, pageCount.value) - 1) * filters.pageSize;
return filteredJobs.value.slice(start, start + filters.pageSize);
});
const runningCount = computed(() => props.jobs.filter((job) => ['queued', 'running'].includes(job.status)).length);
const cancellableCount = computed(() => props.jobs.filter((job) => job.canCancel).length);
const backgroundCount = computed(() => props.jobs.filter((job) => job.type === 'background').length);
watch(() => [filters.search, filters.type, filters.status, filters.pageSize], () => { filters.page = 1; });
function setSort(key) {
if (filters.sort === key) filters.direction = filters.direction === 'asc' ? 'desc' : 'asc';
else {
filters.sort = key;
filters.direction = 'asc';
}
}
function openCancel(row) {
cancelTarget.value = row;
cancelReason.value = '';
}
function submitCancel() {
if (!cancelTarget.value) return;
emit('cancel', { id: cancelTarget.value.id, reason: cancelReason.value.trim() });
cancelTarget.value = null;
}
function sortableValue(row, key) {
if (key === 'startedAt') return row.startedAt || row.createdAt || row.metadata?.lastRunAt || '';
return String(row[key] || '');
}
function shortDate(value) {
if (!value) return '';
return new Date(value).toLocaleString();
}
function summaryText(summary = {}) {
return Object.entries(summary).map(([key, value]) => `${key}: ${value}`).join(', ') || 'completed';
}
</script>

View File

@@ -0,0 +1,18 @@
import { cancelSystemJob, listSystemJobs, runSystemJob } from '../services/systemJobService.js';
export function index(req, res) {
res.json(listSystemJobs());
}
export async function runNow(req, res) {
const result = await runSystemJob(req.params.jobId, req.user.id);
if (!result.ok) return res.status(409).json(result);
res.json(result);
}
export function cancel(req, res) {
const result = cancelSystemJob(req.params.jobId, req.user.id, req.body?.reason || '');
if (!result.ok) return res.status(409).json(result);
res.json(result);
}

View File

@@ -68,13 +68,18 @@ const operationsArticles = [
]), ]),
section('Host Groups and VMware sources', [ 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.', '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.', 'Config / VMware supports two connection types: vCenter inventory systems and standalone ESXi hosts. Both 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.' 'Standalone ESXi host connections use the vSphere Web Services API at /sdk. In the import wizard, selecting one enumerates VirtualMachine managed objects directly from that ESXi host with RetrieveServiceContent, Login, CreateContainerView, and RetrievePropertiesEx.'
]), ]),
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.',
'RunPlans can be personal, shared, or group-scoped. Use the execute action to queue the plan.' 'RunPlans can be personal, shared, or group-scoped. Use the execute action to queue the plan.'
]), ]),
section('System Jobs', [
'Admins can open System Jobs to see queued/running script executions, controllable background workers, and recent management actions in one place.',
'Use Run now for supported background workers such as dynamic Host Group sync, application catalog auto-check, and Intune auto-promotion. Use Cancel on stuck execution jobs to mark queued/running hosts canceled and signal active PowerShell child processes.',
'Every run-now and cancel action is written to the system job action audit trail with actor, target, status, message, and timestamp.'
]),
section('Logs', [ section('Logs', [
'Job logs are aggregated by job and host. System Logs show Winston request/application logs for API request and response troubleshooting.' 'Job logs are aggregated by job and host. System Logs show Winston request/application logs for API request and response troubleshooting.'
]) ])
@@ -129,11 +134,14 @@ const apiArticles = [
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/system-jobs', 'Admin-only control plane for execution jobs, background workers, and recent management audit actions.'),
apiExample('Post', '/api/system-jobs/worker%3Ahost-group-sync/run', 'Run a supported background worker immediately. Other worker ids include worker:catalog-auto-check and worker:intune-auto-promote.'),
apiExample('Post', '/api/system-jobs/job_123/cancel', 'Cancel a queued or running execution job and audit the reason.', '@{ reason = "Operator canceled stuck remoting session" }'),
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', [ 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.', '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.' 'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Import preview enumerates VM inventory from the selected ESXi host, applies the same filter fields as vCenter imports, and stores the VM managed-object reference for later power-state checks.'
], '@{\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}') ], '@{\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.', [

View File

@@ -471,6 +471,19 @@ export function migrate() {
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS system_job_actions (
id TEXT PRIMARY KEY,
job_id TEXT,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL, value TEXT NOT NULL,
@@ -515,6 +528,7 @@ export function migrate() {
// VMware connection type separates vCenter inventory/power-state APIs from // VMware connection type separates vCenter inventory/power-state APIs from
// standalone ESXi host checks that use the vSphere Web Services SOAP API. // standalone ESXi host checks that use the vSphere Web Services SOAP API.
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'"); ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'");
// 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');

View File

@@ -399,3 +399,19 @@ export function camelJobLog(row) {
createdAt: row.created_at createdAt: row.created_at
}; };
} }
export function camelSystemJobAction(row) {
return {
id: row.id,
jobId: row.job_id || null,
targetType: row.target_type,
targetId: row.target_id,
action: row.action,
status: row.status,
message: row.message || '',
actorUserId: row.actor_user_id || null,
actorName: row.actor_name || null,
metadata: parseJson(row.metadata_json, {}),
createdAt: row.created_at
};
}

View File

@@ -0,0 +1,55 @@
import { db, id, json, now } from '../db.js';
import { camelSystemJobAction } from './mappers.js';
export function recordSystemJobAction({
jobId = null,
targetType,
targetId,
action,
status,
message = '',
actorUserId = null,
metadata = {}
}) {
const actionId = id('sja');
db.prepare(`
INSERT INTO system_job_actions (
id, job_id, target_type, target_id, action, status, message,
actor_user_id, metadata_json, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
actionId,
jobId,
targetType,
targetId,
action,
status,
message,
actorUserId,
json(metadata),
now()
);
return getSystemJobAction(actionId);
}
export function getSystemJobAction(actionId) {
const row = db.prepare(`
SELECT a.*, u.display_name AS actor_name
FROM system_job_actions a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.id = ?
`).get(actionId);
return row ? camelSystemJobAction(row) : null;
}
export function listSystemJobActions(limit = 200) {
return db.prepare(`
SELECT a.*, u.display_name AS actor_name
FROM system_job_actions a
LEFT JOIN users u ON u.id = a.actor_user_id
ORDER BY a.created_at DESC
LIMIT ?
`).all(Math.max(1, Math.min(Number(limit) || 200, 500))).map(camelSystemJobAction);
}

View File

@@ -16,6 +16,7 @@ import { packagingRoutes } from './packagingRoutes.js';
import { psadtRoutes } from './psadtRoutes.js'; import { psadtRoutes } from './psadtRoutes.js';
import { runPlanRoutes } from './runPlanRoutes.js'; import { runPlanRoutes } from './runPlanRoutes.js';
import { settingsRoutes } from './settingsRoutes.js'; import { settingsRoutes } from './settingsRoutes.js';
import { systemJobRoutes } from './systemJobRoutes.js';
import { userRoutes } from './userRoutes.js'; import { userRoutes } from './userRoutes.js';
import { variableRoutes } from './variableRoutes.js'; import { variableRoutes } from './variableRoutes.js';
@@ -41,4 +42,5 @@ apiRoutes.use('/intune/applications', applicationRoutes);
apiRoutes.use('/intune', governanceRoutes); apiRoutes.use('/intune', governanceRoutes);
apiRoutes.use('/runplans', runPlanRoutes); apiRoutes.use('/runplans', runPlanRoutes);
apiRoutes.use('/jobs', jobRoutes); apiRoutes.use('/jobs', jobRoutes);
apiRoutes.use('/system-jobs', systemJobRoutes);
apiRoutes.use('/logs', logRoutes); apiRoutes.use('/logs', logRoutes);

View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import { cancel, index, runNow } from '../controllers/systemJobController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const systemJobRoutes = Router();
// Control-plane job management is admin-only and every action is audited.
systemJobRoutes.get('/', requireAuth, requireAdmin, index);
systemJobRoutes.post('/:jobId/run', requireAuth, requireAdmin, runNow);
systemJobRoutes.post('/:jobId/cancel', requireAuth, requireAdmin, cancel);

View File

@@ -14,6 +14,8 @@ import { path } from '../utils/pathUtils.js';
import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js'; import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js';
import { normalizeOsFamily } from '../utils/osFamily.js'; import { normalizeOsFamily } from '../utils/osFamily.js';
const activeJobs = new Map();
// 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
// value, when present, takes precedence so the UI toggle is actually effective. // value, when present, takes precedence so the UI toggle is actually effective.
@@ -181,16 +183,33 @@ 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 state = { canceled: false, children: new Map() };
const runner = async (host) => runHost(jobId, script, host, executionContext); activeJobs.set(jobId, state);
if (parallel) { try {
await Promise.all(hosts.map(runner)); logCompatibilityPreflight(jobId, script, hosts);
} else { const runner = async (host) => runHost(jobId, script, host, executionContext);
for (const host of hosts) await runner(host); if (parallel) {
} await Promise.all(hosts.map(runner));
} else {
for (const host of hosts) {
if (isJobCanceled(jobId)) {
cancelQueuedHosts(jobId);
break;
}
await runner(host);
}
}
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count; if (isJobCanceled(jobId)) {
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId); cancelQueuedHosts(jobId);
db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId);
return;
}
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
} finally {
activeJobs.delete(jobId);
}
} }
function logCompatibilityPreflight(jobId, script, hosts) { function logCompatibilityPreflight(jobId, script, hosts) {
@@ -204,6 +223,10 @@ function logCompatibilityPreflight(jobId, script, hosts) {
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.
if (isJobCanceled(jobId)) {
finishHost(jobId, host.id, 'canceled', 130);
return;
}
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')}`); appendLog(jobId, host.id, 'system', `Target OS type: ${normalizeOsFamily(host.os_family || host.osFamily, 'other')}`);
@@ -233,6 +256,7 @@ async function runHost(jobId, script, host, executionContext) {
async function vCenterPowerPreflight(jobId, host) { async function vCenterPowerPreflight(jobId, host) {
if ((host.source_type || 'manual') !== 'vmware') return true; if ((host.source_type || 'manual') !== 'vmware') return true;
if (isJobCanceled(jobId)) return false;
if (!host.source_connection_id || !host.source_ref) { if (!host.source_connection_id || !host.source_ref) {
appendLog(jobId, host.id, 'system', 'Host is marked as VMware-sourced, but no vCenter connection or VM reference is stored. Continuing without a power-state gate.'); appendLog(jobId, host.id, 'system', 'Host is marked as VMware-sourced, but no vCenter connection or VM reference is stored. Continuing without a power-state gate.');
return true; return true;
@@ -328,11 +352,22 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
}, },
shell: false shell: false
}); });
const active = activeJobs.get(jobId);
if (active) {
active.children.set(host.id, child);
if (active.canceled) {
child.kill('SIGTERM');
setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
}, 5000).unref?.();
}
}
let settled = false; let settled = false;
function settle(status, exitCode) { function settle(status, exitCode) {
if (settled) return; if (settled) return;
settled = true; settled = true;
activeJobs.get(jobId)?.children.delete(host.id);
finishHost(jobId, host.id, status, exitCode); finishHost(jobId, host.id, status, exitCode);
resolve(); resolve();
} }
@@ -346,6 +381,10 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (settled) return; if (settled) return;
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`); if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
if (isJobCanceled(jobId)) {
settle('canceled', 130);
return;
}
const exitCode = code ?? 1; const exitCode = code ?? 1;
settle(exitCode === 0 ? 'completed' : 'failed', exitCode); settle(exitCode === 0 ? 'completed' : 'failed', exitCode);
}); });
@@ -366,6 +405,10 @@ function appendLog(jobId, hostId, stream, line) {
`).run(id('log'), jobId, hostId, stream, line, now()); `).run(id('log'), jobId, hostId, stream, line, now());
} }
export function appendJobLog(jobId, stream, line, hostId = null) {
appendLog(jobId, hostId, stream, line);
}
function finishHost(jobId, hostId, status, exitCode) { function finishHost(jobId, hostId, status, exitCode) {
db.prepare(` db.prepare(`
UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ? UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ?
@@ -374,6 +417,52 @@ function finishHost(jobId, hostId, status, exitCode) {
appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`); appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`);
} }
function cancelQueuedHosts(jobId) {
const timestamp = now();
const rows = db.prepare('SELECT host_id FROM job_hosts WHERE job_id = ? AND status IN (?, ?)').all(jobId, 'queued', 'running');
db.prepare(`
UPDATE job_hosts
SET status = 'canceled', exit_code = COALESCE(exit_code, 130), ended_at = COALESCE(ended_at, ?)
WHERE job_id = ? AND status IN ('queued', 'running')
`).run(timestamp, jobId);
for (const row of rows) appendLog(jobId, row.host_id, 'system', 'Host execution canceled by job management.');
}
function isJobCanceled(jobId) {
if (activeJobs.get(jobId)?.canceled) return true;
const row = db.prepare('SELECT status FROM jobs WHERE id = ?').get(jobId);
return row?.status === 'canceled';
}
export function activeExecutionJobIds() {
return [...activeJobs.keys()];
}
export function cancelExecutionJob(jobId, { reason = '', actorUserId = '' } = {}) {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
if (!job) return { canceled: false, reason: 'Job not found.' };
if (!['queued', 'running'].includes(job.status)) {
return { canceled: false, reason: `Job is ${job.status} and cannot be canceled.` };
}
const active = activeJobs.get(jobId);
if (active) active.canceled = true;
const children = active ? [...active.children.values()] : [];
appendLog(jobId, null, 'system', `Cancel requested${actorUserId ? ` by ${actorUserId}` : ''}${reason ? `: ${reason}` : '.'}`);
db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId);
cancelQueuedHosts(jobId);
for (const child of children) {
try {
child.kill('SIGTERM');
setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
}, 5000).unref?.();
} catch (error) {
logger.warn('failed to signal child process during job cancel', { jobId, error: error.message });
}
}
return { canceled: true, signaledProcesses: children.length };
}
function runtimeVariableValues(host, executionContext) { function runtimeVariableValues(host, executionContext) {
const assetManifest = JSON.stringify(executionContext.assets || []); const assetManifest = JSON.stringify(executionContext.assets || []);
return [ return [

View File

@@ -0,0 +1,257 @@
import { db, now } from '../db.js';
import { camelJob } from '../models/mappers.js';
import { listSystemJobActions, recordSystemJobAction } from '../models/systemJobModel.js';
import { syncDynamicHostGroups } from '../models/hostGroupModel.js';
import { runCatalogChecks } from './catalogWorker.js';
import { runAutoPromotions } from './promotionWorker.js';
import { cancelExecutionJob, appendJobLog, activeExecutionJobIds } from './powershellRunner.js';
const backgroundRuns = new Map();
const BACKGROUND_JOBS = [
{
id: 'worker:host-group-sync',
name: 'Dynamic Host Group Sync',
category: 'Inventory',
description: 'Reconciles rule-based Host Groups against the current Host Library.',
run: async ({ signal } = {}) => syncDynamicHostGroups({ signal })
},
{
id: 'worker:catalog-auto-check',
name: 'Application Catalog Auto-Check',
category: 'Applications',
description: 'Checks opted-in application records for newer upstream versions.',
run: async ({ signal } = {}) => runCatalogChecks({ signal })
},
{
id: 'worker:intune-auto-promote',
name: 'Intune Auto-Promotion',
category: 'Intune',
description: 'Promotes deployment rings when configured soak windows elapse.',
run: async ({ signal } = {}) => runAutoPromotions({ signal })
}
];
export function listSystemJobs() {
return {
jobs: [...executionJobs(), ...backgroundJobs()],
actions: listSystemJobActions(200),
activeExecutionJobIds: activeExecutionJobIds()
};
}
export async function runSystemJob(targetId, actorUserId) {
const definition = BACKGROUND_JOBS.find((job) => job.id === targetId);
if (!definition) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: 'failed',
message: 'System job not found.',
actorUserId
});
return { ok: false, action, error: 'System job not found.' };
}
if (backgroundRuns.get(targetId)?.running) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: 'skipped',
message: `${definition.name} is already running.`,
actorUserId
});
return { ok: false, action, error: `${definition.name} is already running.` };
}
const controller = new AbortController();
backgroundRuns.set(targetId, {
running: true,
cancelRequested: false,
startedAt: now(),
lastError: '',
lastSummary: null,
controller
});
const startAction = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: 'started',
message: `${definition.name} started by operator.`,
actorUserId
});
try {
const summary = await definition.run({ signal: controller.signal });
const canceled = Boolean(backgroundRuns.get(targetId)?.cancelRequested || controller.signal.aborted);
backgroundRuns.set(targetId, {
running: false,
cancelRequested: false,
startedAt: '',
lastRunAt: now(),
lastError: canceled ? 'Canceled by operator.' : '',
lastSummary: summary
});
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: canceled ? 'canceled' : 'success',
message: canceled ? `${definition.name} canceled by operator.` : `${definition.name} completed.`,
actorUserId,
metadata: { summary, startActionId: startAction.id }
});
return { ok: !canceled, action, summary, canceled };
} catch (error) {
const canceled = Boolean(backgroundRuns.get(targetId)?.cancelRequested || controller.signal.aborted);
backgroundRuns.set(targetId, {
running: false,
cancelRequested: false,
startedAt: '',
lastRunAt: now(),
lastError: canceled ? 'Canceled by operator.' : error.message,
lastSummary: null
});
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: canceled ? 'canceled' : 'failed',
message: canceled ? `${definition.name} canceled by operator.` : error.message,
actorUserId,
metadata: { startActionId: startAction.id }
});
return { ok: false, action, error: canceled ? 'Canceled by operator.' : error.message, canceled };
}
}
export function cancelSystemJob(targetId, actorUserId, reason = '') {
if (targetId.startsWith('worker:')) {
const definition = BACKGROUND_JOBS.find((job) => job.id === targetId);
const state = backgroundRuns.get(targetId);
if (!definition || !state?.running) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'cancel',
status: 'failed',
message: definition ? `${definition.name} is not running.` : 'System job not found.',
actorUserId,
metadata: { reason }
});
return { ok: false, action, error: action.message };
}
state.cancelRequested = true;
state.controller?.abort?.(new Error(reason || 'Canceled by operator.'));
backgroundRuns.set(targetId, state);
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'cancel',
status: 'success',
message: `Cancel requested for ${definition.name}.`,
actorUserId,
metadata: { reason }
});
return { ok: true, action, result: { canceled: true, targetType: 'background' } };
}
if (!targetId.startsWith('job_')) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'cancel',
status: 'failed',
message: 'Only running script execution jobs can be canceled.',
actorUserId,
metadata: { reason }
});
return { ok: false, action, error: 'Only running script execution jobs can be canceled.' };
}
const result = cancelExecutionJob(targetId, { actorUserId, reason });
const status = result.canceled ? 'success' : 'failed';
const message = result.canceled
? `Cancel requested; signaled ${result.signaledProcesses || 0} running process(es).`
: result.reason;
const action = recordSystemJobAction({
jobId: targetId,
targetType: 'execution',
targetId,
action: 'cancel',
status,
message,
actorUserId,
metadata: { reason, result }
});
if (result.canceled) appendJobLog(targetId, 'system', `Job management action logged: cancel (${action.id}).`, null);
return { ok: result.canceled, action, result, error: result.canceled ? null : result.reason };
}
function executionJobs() {
const rows = db.prepare(`
SELECT j.*, r.name AS runplan_name, s.name AS script_name,
COUNT(jh.id) AS host_count,
SUM(CASE WHEN jh.status = 'running' THEN 1 ELSE 0 END) AS running_hosts,
SUM(CASE WHEN jh.status = 'queued' THEN 1 ELSE 0 END) AS queued_hosts
FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
LEFT JOIN scripts s ON s.id = j.script_id
LEFT JOIN job_hosts jh ON jh.job_id = j.id
GROUP BY j.id
ORDER BY j.created_at DESC
LIMIT 200
`).all();
return rows.map((row) => {
const job = camelJob(row);
const running = ['queued', 'running'].includes(job.status);
return {
id: job.id,
type: 'execution',
category: 'Script Execution',
name: job.runplanName || job.scriptName || job.id,
description: job.runplanName ? `RunPlan ${job.runplanName}` : `Script test ${job.scriptName || job.id}`,
status: job.status,
canCancel: running,
canRunNow: false,
startedAt: job.startedAt || '',
endedAt: job.endedAt || '',
createdAt: job.createdAt,
hostCount: row.host_count || 0,
runningHosts: row.running_hosts || 0,
queuedHosts: row.queued_hosts || 0,
metadata: {
runplanId: job.runplanId,
scriptId: job.scriptId,
triggeredBy: job.triggeredBy
}
};
});
}
function backgroundJobs() {
return BACKGROUND_JOBS.map((job) => {
const state = backgroundRuns.get(job.id) || {};
return {
id: job.id,
type: 'background',
category: job.category,
name: job.name,
description: job.description,
status: state.cancelRequested ? 'canceling' : state.running ? 'running' : 'idle',
canCancel: Boolean(state.running),
canRunNow: !state.running,
startedAt: state.startedAt || '',
endedAt: '',
createdAt: '',
hostCount: 0,
runningHosts: 0,
queuedHosts: 0,
metadata: {
lastRunAt: state.lastRunAt || '',
lastError: state.lastError || '',
lastSummary: state.lastSummary || null
}
};
});
}

View File

@@ -272,6 +272,31 @@ function extractSoapTag(text, tagName) {
return match ? match[1].trim() : ''; return match ? match[1].trim() : '';
} }
function extractSoapBlocks(text, tagName) {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'gi');
return [...String(text || '').matchAll(pattern)].map((match) => match[1]);
}
function decodeXml(value) {
return String(value ?? '')
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
function extractManagedObject(text, tagName, fallbackType = '') {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}([^>]*)>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
const match = String(text || '').match(pattern);
if (!match) return { type: fallbackType, value: '' };
const typeMatch = String(match[1] || '').match(/\btype=["']([^"']+)["']/i);
return {
type: typeMatch?.[1] || fallbackType,
value: decodeXml(match[2].trim())
};
}
function extractSoapFault(text) { function extractSoapFault(text) {
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || ''; return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
} }
@@ -399,6 +424,16 @@ function localizedText(value) {
return String(value); return String(value);
} }
function normalizePowerState(value) {
const text = String(value || '').trim();
const map = {
poweredon: 'POWERED_ON',
poweredoff: 'POWERED_OFF',
suspended: 'SUSPENDED'
};
return map[text.toLowerCase()] || text;
}
export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) { export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
const interfaces = unwrapList(interfacesPayload); const interfaces = unwrapList(interfacesPayload);
const interfaceIp = firstIpFromInterfaces(interfaces); const interfaceIp = firstIpFromInterfaces(interfaces);
@@ -415,7 +450,7 @@ export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null
address, address,
ipAddress: ipAddresses[0] || '', ipAddress: ipAddresses[0] || '',
ipAddresses, ipAddresses,
powerState: vm.power_state || vm.powerState || '', powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
osFamily: inferOsFamily(identity || {}), osFamily: inferOsFamily(identity || {}),
guestFullName: localizedText(identity?.full_name) || identity?.name || '', guestFullName: localizedText(identity?.full_name) || identity?.name || '',
toolsAvailable: Boolean(identity || interfaces.length), toolsAvailable: Boolean(identity || interfaces.length),
@@ -431,7 +466,7 @@ function normalizeVCenterSummaryVm(vm) {
address: vm.name || vm.vm || vm.id || '', address: vm.name || vm.vm || vm.id || '',
ipAddress: '', ipAddress: '',
ipAddresses: [], ipAddresses: [],
powerState: vm.power_state || vm.powerState || '', powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
osFamily: 'other', osFamily: 'other',
guestFullName: '', guestFullName: '',
toolsAvailable: false, toolsAvailable: false,
@@ -439,6 +474,31 @@ function normalizeVCenterSummaryVm(vm) {
}; };
} }
function normalizeStandaloneVmObject(object, connection = null) {
const props = object.props || {};
const guestHostName = props['guest.hostName'] || props['summary.guest.hostName'] || '';
const guestIp = props['guest.ipAddress'] || props['summary.guest.ipAddress'] || '';
const guestFullName = props['guest.guestFullName'] || props['summary.config.guestFullName'] || '';
const guestId = props['guest.guestId'] || props['summary.config.guestId'] || '';
const name = props.name || guestHostName || object.id;
return {
id: object.id,
name,
fqdn: guestHostName,
address: guestHostName || guestIp || name,
ipAddress: guestIp,
ipAddresses: guestIp ? [guestIp] : [],
powerState: normalizePowerState(props['runtime.powerState'] || props['summary.runtime.powerState'] || ''),
osFamily: inferOsFamily({ full_name: guestFullName, guest_OS: guestId, host_name: guestHostName }),
guestFullName,
toolsAvailable: Boolean(guestHostName || guestIp || guestFullName || guestId),
source: 'standalone-vm',
sourceConnectionId: connection?.id || null,
sourceConnectionName: connection?.name || '',
targetType: 'host'
};
}
export function vmMatchesFilter(candidate, filter = {}) { export function vmMatchesFilter(candidate, filter = {}) {
const operator = filter.operator || 'contains'; const operator = filter.operator || 'contains';
const needle = String(filter.value || '').trim().toLowerCase(); const needle = String(filter.value || '').trim().toLowerCase();
@@ -494,6 +554,8 @@ export function buildVmListPath(profile, filter = {}) {
} }
export function buildHostPayloadFromVm(candidate, options = {}) { export function buildHostPayloadFromVm(candidate, options = {}) {
if (candidate.source === 'standalone-host') return buildHostPayloadFromStandaloneHost(candidate, options);
if (candidate.source === 'standalone-vm') return buildHostPayloadFromStandaloneVm(candidate, options);
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))]; const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
return { return {
name: candidate.name, name: candidate.name,
@@ -518,6 +580,209 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
}; };
} }
export function buildHostPayloadFromStandaloneVm(candidate, options = {}) {
const tags = [...new Set([
'vmware',
'esxi',
'standalone-esxi',
candidate.id ? `esxi-vm:${candidate.id}` : '',
...(options.tags || [])
].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from standalone VMware ESXi VM ${candidate.id}.`,
candidate.sourceConnectionName || candidate.sourceConnectionId
? `Source ESXi connection: ${candidate.sourceConnectionName || candidate.sourceConnectionId}.`
: '',
candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: candidate.sourceConnectionId || options.connectionId || null,
sourceRef: candidate.id || ''
};
}
export function buildStandaloneHostCandidate(connection, settings = settingsFromVCenterConnection(connection)) {
const hostname = hostnameFromBaseUrl(settings.baseUrl);
const name = connection?.name || hostname || settings.baseUrl;
return {
id: `standalone-host:${connection?.id || hostname || settings.baseUrl}`,
name,
fqdn: hostname,
address: hostname || settings.baseUrl,
ipAddress: '',
ipAddresses: [],
powerState: 'Standalone host',
osFamily: 'other',
guestFullName: 'Standalone VMware ESXi host',
toolsAvailable: false,
source: 'standalone-host',
sourceConnectionId: connection?.id || null,
sourceConnectionName: connection?.name || '',
targetType: 'host'
};
}
export function buildHostPayloadFromStandaloneHost(candidate, options = {}) {
const tags = [...new Set([
'vmware',
'esxi',
'standalone-esxi',
candidate.sourceConnectionId ? `vmware-host:${candidate.sourceConnectionId}` : '',
...(options.tags || [])
].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.fqdn || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'ssh',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from standalone VMware ESXi connection ${candidate.sourceConnectionName || candidate.sourceConnectionId || candidate.name}.`,
'This host was added from a direct VMware host connection, not vCenter VM inventory.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: candidate.sourceConnectionId || options.connectionId || null,
sourceRef: 'standalone-esxi-host'
};
}
function hostnameFromBaseUrl(value) {
try {
return new URL(value).hostname;
} catch {
return String(value || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
}
}
function soapRefElement(name, ref, fallbackType) {
return `<${name} type="${xmlEscape(ref?.type || fallbackType)}">${xmlEscape(ref?.value || '')}</${name}>`;
}
async function connectStandaloneSoap(settings, trace = null) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services',
endpoint: soapEndpoint(settings)
});
const serviceContent = await vSphereSoapFetch(
settings,
'RetrieveServiceContent',
`<RetrieveServiceContent xmlns="urn:vim25"><_this type="ServiceInstance">ServiceInstance</_this></RetrieveServiceContent>`,
trace
);
const refs = {
rootFolder: extractManagedObject(serviceContent, 'rootFolder', 'Folder'),
propertyCollector: extractManagedObject(serviceContent, 'propertyCollector', 'PropertyCollector'),
viewManager: extractManagedObject(serviceContent, 'viewManager', 'ViewManager'),
sessionManager: extractManagedObject(serviceContent, 'sessionManager', 'SessionManager')
};
await vSphereSoapFetch(
settings,
'Login',
`<Login xmlns="urn:vim25">${soapRefElement('_this', refs.sessionManager, 'SessionManager')}<userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></Login>`,
trace
);
return refs;
}
async function createVmContainerView(settings, refs, trace = null) {
const response = await vSphereSoapFetch(
settings,
'CreateContainerView',
`<CreateContainerView xmlns="urn:vim25">${soapRefElement('_this', refs.viewManager, 'ViewManager')}${soapRefElement('container', refs.rootFolder, 'Folder')}<type>VirtualMachine</type><recursive>true</recursive></CreateContainerView>`,
trace
);
return extractManagedObject(response, 'returnval', 'ContainerView');
}
function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
const paths = [
'name',
'guest.hostName',
'guest.ipAddress',
'guest.guestFullName',
'guest.guestId',
'runtime.powerState',
'summary.guest.hostName',
'summary.guest.ipAddress',
'summary.config.guestFullName',
'summary.config.guestId',
'summary.runtime.powerState'
];
return `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` +
`<specSet>` +
`<propSet><type>VirtualMachine</type><all>false</all>${paths.map((pathSet) => `<pathSet>${pathSet}</pathSet>`).join('')}</propSet>` +
`<objectSet>${soapRefElement('obj', viewRef, 'ContainerView')}<skip>true</skip>` +
`<selectSet xsi:type="TraversalSpec"><name>containerViewTraversal</name><type>ContainerView</type><path>view</path><skip>false</skip></selectSet>` +
`</objectSet>` +
`</specSet>` +
`<options><maxObjects>${Math.max(1, Math.min(Number(limit) || 100, 500))}</maxObjects></options>` +
`</RetrievePropertiesEx>`;
}
function continuePropertiesBody(propertyCollector, token) {
return `<ContinueRetrievePropertiesEx xmlns="urn:vim25">${soapRefElement('_this', propertyCollector, 'PropertyCollector')}<token>${xmlEscape(token)}</token></ContinueRetrievePropertiesEx>`;
}
function parseVmPropertyObjects(xml) {
return extractSoapBlocks(xml, 'objects').map((block) => {
const obj = extractManagedObject(block, 'obj', 'VirtualMachine');
const props = {};
for (const propBlock of extractSoapBlocks(block, 'propSet')) {
const name = decodeXml(extractSoapTag(propBlock, 'name'));
const rawValue = extractSoapTag(propBlock, 'val');
if (name) props[name] = decodeXml(rawValue.replace(/<[^>]+>/g, '').trim());
}
return { id: obj.value, type: obj.type, props };
}).filter((object) => object.id);
}
async function retrieveStandaloneVmInventory(settings, { connection = null, limit = 100, trace = null } = {}) {
const refs = await connectStandaloneSoap(settings, trace);
const viewRef = await createVmContainerView(settings, refs, trace);
const objects = [];
let response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', retrievePropertiesBody(refs.propertyCollector, viewRef, limit), trace);
objects.push(...parseVmPropertyObjects(response));
let token = decodeXml(extractSoapTag(response, 'token'));
while (token && objects.length < limit) {
response = await vSphereSoapFetch(settings, 'ContinueRetrievePropertiesEx', continuePropertiesBody(refs.propertyCollector, token), trace);
objects.push(...parseVmPropertyObjects(response));
token = decodeXml(extractSoapTag(response, 'token'));
}
return objects.slice(0, limit).map((object) => normalizeStandaloneVmObject(object, connection));
}
async function retrieveStandaloneVmPowerState(settings, vmId, trace = null) {
const refs = await connectStandaloneSoap(settings, trace);
const vmRef = { type: 'VirtualMachine', value: vmId };
const body = `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', refs.propertyCollector, 'PropertyCollector')}` +
`<specSet><propSet><type>VirtualMachine</type><all>false><pathSet>runtime.powerState</pathSet></propSet>` +
`<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` +
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`;
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace);
const object = parseVmPropertyObjects(response)[0];
return normalizePowerState(object?.props?.['runtime.powerState'] || '');
}
export async function previewVCenterHosts(options = {}) { export async function previewVCenterHosts(options = {}) {
const trace = []; const trace = [];
const settings = settingsFromVCenterConnection(options.connection); const settings = settingsFromVCenterConnection(options.connection);
@@ -527,7 +792,37 @@ export async function previewVCenterHosts(options = {}) {
throw new VCenterTraceError(err.message, trace, 400); throw new VCenterTraceError(err.message, trace, 400);
} }
if (settings.targetType === 'host') { 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 allVms = await retrieveStandaloneVmInventory(settings, {
connection: options.connection,
limit: options.limit || 100,
trace
});
const filtered = allVms.filter((candidate) => vmMatchesFilter(candidate, options.filter)).slice(0, options.limit || 100);
pushTraceInfo(trace, 'Enumerated standalone ESXi VM inventory through the vSphere Web Services API.', {
connectionId: options.connection?.id || null,
baseUrl: settings.baseUrl,
totalFromHost: allVms.length,
matched: filtered.length
});
return {
status: vcenterConnectionStatus(options.connection),
filter: options.filter || {},
count: filtered.length,
diagnostics: {
targetType: 'host',
apiMode: 'web-services',
apiProfile: WEB_SERVICES_PROFILE_LABEL,
totalFromVCenter: allVms.length,
summaryMatched: filtered.length,
scanned: allVms.length,
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.id).filter(Boolean),
standaloneHost: true,
filter: options.filter || {}
},
trace,
candidates: filtered
};
} }
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);
@@ -586,29 +881,17 @@ export async function testVCenterConnection(connection) {
} }
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) { export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, { const vms = await retrieveStandaloneVmInventory(settings, {
mode: 'web-services', connection,
endpoint: soapEndpoint(settings) limit: 1,
trace
}); });
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 { return {
status: vcenterConnectionStatus(connection), status: vcenterConnectionStatus(connection),
apiProfile: WEB_SERVICES_PROFILE_LABEL, apiProfile: WEB_SERVICES_PROFILE_LABEL,
count: 0, count: vms.length,
trace, trace,
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}.` message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}; VM inventory query returned ${vms.length} row(s).`
}; };
} }
@@ -616,7 +899,14 @@ 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') { if (settings.targetType === 'host') {
return { checked: false, reason: 'Standalone VMware host connections do not provide vCenter VM power-state inventory lookups.' }; assertConfigured(settings);
const state = await retrieveStandaloneVmPowerState(settings, vmId, trace);
return {
checked: Boolean(state),
state,
apiProfile: WEB_SERVICES_PROFILE_LABEL,
reason: state ? '' : 'VM power state was not returned by the ESXi Web Services API.'
};
} }
assertConfigured(settings); assertConfigured(settings);
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode]; const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const { listJobs, getJob } = await load('../models/jobModel.js'); const { listJobs, getJob } = await load('../models/jobModel.js');
const { cancelSystemJob, listSystemJobs, runSystemJob } = await load('../services/systemJobService.js');
const owner = makeUser({ email: 'job-owner@test.local' }); const owner = makeUser({ email: 'job-owner@test.local' });
const stranger = makeUser({ email: 'job-stranger@test.local' }); const stranger = makeUser({ email: 'job-stranger@test.local' });
@@ -23,6 +24,11 @@ db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, c
const scriptTestJobId = 'job_script_test_1'; const scriptTestJobId = 'job_script_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at) db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at)
VALUES (?, NULL, ?, 'failed', ?, ?)`).run(scriptTestJobId, scriptId, owner, ts); VALUES (?, NULL, ?, 'failed', ?, ?)`).run(scriptTestJobId, scriptId, owner, ts);
const runningJobId = 'job_cancel_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)`).run(runningJobId, runplanId, scriptId, owner, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES ('jhost_cancel_test_1', ?, NULL, 'queued', NULL)`).run(runningJobId);
test('the triggering operator can list and read their job', () => { test('the triggering operator can list and read their job', () => {
assert.ok(listJobs(owner, false).some((j) => j.id === jobId)); assert.ok(listJobs(owner, false).some((j) => j.id === jobId));
@@ -45,3 +51,36 @@ test('admins can see any job', () => {
assert.ok(listJobs(admin, true).some((j) => j.id === jobId)); assert.ok(listJobs(admin, true).some((j) => j.id === jobId));
assert.ok(getJob(jobId, admin, true)); assert.ok(getJob(jobId, admin, true));
}); });
test('system jobs list execution and background jobs', () => {
const listJobId = 'job_list_system_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)`).run(listJobId, runplanId, scriptId, owner, ts, ts);
const payload = listSystemJobs();
assert.ok(payload.jobs.some((job) => job.id === listJobId && job.canCancel));
assert.ok(payload.jobs.some((job) => job.id === 'worker:host-group-sync' && job.canRunNow));
});
test('cancelSystemJob marks running jobs canceled and audits the action', () => {
const result = cancelSystemJob(runningJobId, admin, 'test cancellation');
assert.equal(result.ok, true);
assert.equal(db.prepare('SELECT status FROM jobs WHERE id = ?').get(runningJobId).status, 'canceled');
assert.equal(db.prepare('SELECT status FROM job_hosts WHERE job_id = ?').get(runningJobId).status, 'canceled');
const action = db.prepare('SELECT * FROM system_job_actions WHERE target_id = ? AND action = ?').get(runningJobId, 'cancel');
assert.equal(action.status, 'success');
});
test('cancelSystemJob audits idle background worker cancellation attempts', () => {
const result = cancelSystemJob('worker:host-group-sync', admin, 'not running');
assert.equal(result.ok, false);
assert.match(result.error, /not running/i);
const action = db.prepare('SELECT * FROM system_job_actions WHERE target_id = ? AND action = ? ORDER BY created_at DESC').get('worker:host-group-sync', 'cancel');
assert.equal(action.status, 'failed');
});
test('runSystemJob executes host group sync and audits run-now', async () => {
const result = await runSystemJob('worker:host-group-sync', admin);
assert.equal(result.ok, true);
const action = db.prepare('SELECT * FROM system_job_actions WHERE target_id = ? AND action = ? ORDER BY created_at DESC').get('worker:host-group-sync', 'run-now');
assert.ok(action);
});

View File

@@ -6,7 +6,9 @@ import { load } from './setup.mjs';
const { const {
buildVmListPath, buildVmListPath,
buildHostPayloadFromVm, buildHostPayloadFromVm,
buildStandaloneHostCandidate,
filterSummaryVms, filterSummaryVms,
getVCenterVmPowerState,
normalizeBaseUrl, normalizeBaseUrl,
normalizeVCenterVm, normalizeVCenterVm,
previewVCenterHosts, previewVCenterHosts,
@@ -14,29 +16,124 @@ const {
vmMatchesFilter vmMatchesFilter
} = await load('../services/vcenterService.js'); } = await load('../services/vcenterService.js');
const originalFetch = globalThis.fetch;
function soapResponse(body) {
return new Response(`<?xml version="1.0" encoding="UTF-8"?><soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"><soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`, {
status: 200,
headers: { 'content-type': 'text/xml' }
});
}
function installStandaloneEsxiSoapStub() {
globalThis.fetch = async (_url, options = {}) => {
const body = String(options.body || '');
if (body.includes('<RetrieveServiceContent')) {
return soapResponse(`<RetrieveServiceContentResponse xmlns="urn:vim25"><returnval><rootFolder type="Folder">ha-folder-root</rootFolder><propertyCollector type="PropertyCollector">ha-property-collector</propertyCollector><viewManager type="ViewManager">ViewManager</viewManager><sessionManager type="SessionManager">ha-sessionmgr</sessionManager></returnval></RetrieveServiceContentResponse>`);
}
if (body.includes('<Login')) {
return soapResponse(`<LoginResponse xmlns="urn:vim25"><returnval type="UserSession">session-1</returnval></LoginResponse>`);
}
if (body.includes('<CreateContainerView')) {
return soapResponse(`<CreateContainerViewResponse xmlns="urn:vim25"><returnval type="ContainerView">session[52b6]-vm-view</returnval></CreateContainerViewResponse>`);
}
if (body.includes('<RetrievePropertiesEx')) {
return soapResponse(`<RetrievePropertiesExResponse xmlns="urn:vim25"><returnval><objects><obj type="VirtualMachine">vim.VirtualMachine:101</obj><propSet><name>name</name><val>ENG-ENT-APP01</val></propSet><propSet><name>guest.hostName</name><val>eng-ent-app01.contoso.local</val></propSet><propSet><name>guest.ipAddress</name><val>10.42.8.21</val></propSet><propSet><name>guest.guestFullName</name><val>Microsoft Windows Server 2022</val></propSet><propSet><name>runtime.powerState</name><val>poweredOn</val></propSet></objects></returnval></RetrievePropertiesExResponse>`);
}
return soapResponse('');
};
return () => { globalThis.fetch = originalFetch; };
}
test('normalizeBaseUrl trims trailing slashes', () => { 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 () => { test('standalone VMware host connections use the Web Services profile', async () => {
const settings = settingsFromVCenterConnection({ const restoreFetch = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host', id: 'vc_host',
name: 'ESXi 01',
target_type: 'host', target_type: 'host',
base_url: 'https://esxi01.lab.local/sdk', base_url: 'https://esxi01.lab.local/sdk',
username: 'root', username: 'root',
password: 'secret', password: 'secret',
enabled: 1, enabled: 1,
api_mode: 'api' api_mode: 'api'
}); };
const settings = settingsFromVCenterConnection(connection);
assert.equal(settings.targetType, 'host'); assert.equal(settings.targetType, 'host');
assert.equal(settings.apiMode, 'web-services'); assert.equal(settings.apiMode, 'web-services');
assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk'); assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
await assert.rejects( const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } });
() => previewVCenterHosts({ connection: { ...settings, name: 'ESXi host' } }), restoreFetch();
/Standalone VMware host connections/
assert.equal(preview.count, 1);
assert.equal(preview.diagnostics.standaloneHost, true);
assert.equal(preview.candidates[0].source, 'standalone-vm');
assert.equal(preview.candidates[0].fqdn, 'eng-ent-app01.contoso.local');
assert.equal(preview.candidates[0].ipAddress, '10.42.8.21');
assert.equal(preview.candidates[0].powerState, 'POWERED_ON');
});
test('standalone VMware VM candidates build managed host payloads', async () => {
const restoreFetch = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
target_type: 'host',
base_url: 'https://esxi01.lab.local',
username: 'root',
password: 'secret',
enabled: 1
};
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'eng-ent' } });
const payload = buildHostPayloadFromVm(preview.candidates[0], {
connectionId: 'vc_host',
credentialId: 'cred_1',
transport: 'winrm',
tags: ['lab'],
visibility: 'shared'
});
const power = await getVCenterVmPowerState(connection, preview.candidates[0].id);
restoreFetch();
assert.equal(payload.name, 'ENG-ENT-APP01');
assert.equal(payload.address, 'eng-ent-app01.contoso.local');
assert.equal(payload.transport, 'winrm');
assert.equal(payload.sourceType, 'vmware');
assert.equal(payload.sourceConnectionId, 'vc_host');
assert.equal(payload.sourceRef, 'vim.VirtualMachine:101');
assert.ok(payload.tags.includes('standalone-esxi'));
assert.ok(payload.tags.includes('esxi-vm:vim.VirtualMachine:101'));
assert.equal(power.checked, true);
assert.equal(power.state, 'POWERED_ON');
});
test('standalone VMware host candidates build managed host payloads', () => {
const candidate = buildStandaloneHostCandidate(
{ id: 'vc_host', name: 'ESXi 01' },
{ baseUrl: 'https://esxi01.lab.local', targetType: 'host' }
); );
const payload = buildHostPayloadFromVm(candidate, {
connectionId: 'vc_host',
credentialId: 'cred_1',
transport: 'ssh',
tags: ['lab'],
visibility: 'shared'
});
assert.equal(payload.name, 'ESXi 01');
assert.equal(payload.address, 'esxi01.lab.local');
assert.equal(payload.transport, 'ssh');
assert.equal(payload.credentialId, 'cred_1');
assert.equal(payload.sourceType, 'vmware');
assert.equal(payload.sourceConnectionId, 'vc_host');
assert.equal(payload.sourceRef, 'standalone-esxi-host');
assert.ok(payload.tags.includes('standalone-esxi'));
assert.ok(payload.tags.includes('lab'));
}); });
test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => { test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {