Initial
This commit is contained in:
50
README.md
50
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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; });
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
241
client/src/views/SystemJobsView.vue
Normal file
241
client/src/views/SystemJobsView.vue
Normal 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>
|
||||||
18
server/controllers/systemJobController.js
Normal file
18
server/controllers/systemJobController.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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.', [
|
||||||
|
|||||||
14
server/db.js
14
server/db.js
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
55
server/models/systemJobModel.js
Normal file
55
server/models/systemJobModel.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
11
server/routes/systemJobRoutes.js
Normal file
11
server/routes/systemJobRoutes.js
Normal 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);
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
const state = { canceled: false, children: new Map() };
|
||||||
|
activeJobs.set(jobId, state);
|
||||||
|
try {
|
||||||
logCompatibilityPreflight(jobId, script, hosts);
|
logCompatibilityPreflight(jobId, script, hosts);
|
||||||
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
||||||
if (parallel) {
|
if (parallel) {
|
||||||
await Promise.all(hosts.map(runner));
|
await Promise.all(hosts.map(runner));
|
||||||
} else {
|
} else {
|
||||||
for (const host of hosts) await runner(host);
|
for (const host of hosts) {
|
||||||
|
if (isJobCanceled(jobId)) {
|
||||||
|
cancelQueuedHosts(jobId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await runner(host);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isJobCanceled(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;
|
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);
|
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 [
|
||||||
|
|||||||
257
server/services/systemJobService.js
Normal file
257
server/services/systemJobService.js
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/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];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user